mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
fix(sandbox): cut read_file output at a line boundary and name the next start_line (#5474)
* fix(sandbox): cut read_file output at a line boundary and name the next start_line read_file head-truncates at a character offset and its marker told the model to continue with start_line/end_line while reporting only character counts, so the cut usually fell mid-line and the model had to guess which line to continue from. The cut now lands on the last line boundary the budget allows, and the marker reports lines shown of lines total, keeps the character counts, and names the exact next start_line. When the line at the cut is longer than 4,096 characters (minified sources, one-line JSON) the cut stays at the character limit and the marker names the line it fell inside, so a re-read of that line is the continuation. Reads under the limit are unchanged. * fix(sandbox): make the read_file continuation hold for ranged reads and long lines Line numbers in the truncation marker are now file line numbers: read_file_tool passes start_line - 1 as the line offset, so a ranged read that is itself truncated names the right next line instead of one relative to its slice. A ranged read is a provider slice joined with newlines, so the tool also says so and a trailing newline there counts as an empty last line. The long-line fallback now names a continuation only when it makes progress: a read from the cut line when the whole line fits such a read, a single-line read (start_line = end_line) when only the line alone fits max_chars, and bash when even that cannot return it; the single-line form names no further line after the last line of the read. The budget reserves one extra character so a newline sitting exactly at the limit still counts as a complete line, the "fits a fresh read" check uses a pessimistic estimate of the follow-up read's marker, and a budget too small for any marker still returns a marker instead of a bare prefix. Adds unit cases for the ranged-read offset, the newline-at-budget edge, the single-line-read and bash forms, tiny budgets and empty last lines, plus end-to-end tests that drive read_file_tool with a LocalSandbox and follow the markers across reads, asserting the kept segments reproduce the file without gap or overlap. * fix(sandbox): keep naming the next line after a bounded read's last line A ranged read with an end_line below the file's length is a slice that stops mid-file, so the single-line-read continuation must still name the line after the slice's last line; only a read that reached the end of the file names nothing further. The tool passes whether the read was bounded by an end_line separately from the joined-lines hint, because a start_line-only read also runs to the end of the file. A blank line and a line past the end both read back as an empty slice; the tool now tells them apart with a two-line probe, so a continuation named by a marker that lands on a blank line answers "(empty)" rather than "(start_line exceeds file length)". * test(sandbox): adapt upstream continuation checks after rebase --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
e493390aea
commit
0efdf8e7d8
@ -1840,43 +1840,131 @@ def _truncate_bash_output(output: str, max_chars: int) -> str:
|
||||
return f"{output[:head_len]}{marker}{output[-tail_len:] if tail_len > 0 else ''}" + preserved
|
||||
|
||||
|
||||
def _truncate_read_file_output(output: str, max_chars: int, line_offset: int = 0) -> str:
|
||||
# A read_file cut prefers the last line boundary before the limit, so the model
|
||||
# never sees a partial line that reads as complete and the marker can name the
|
||||
# exact next ``start_line``. When the partial line at the cut is longer than
|
||||
# this, dropping it would throw away most of the budget (minified sources,
|
||||
# one-line JSON), so the cut stays at the character limit and the marker names
|
||||
# the line it fell inside instead.
|
||||
_READ_FILE_LINE_CUT_SLACK = 4096
|
||||
|
||||
|
||||
def _read_file_truncation_marker(*, line_offset: int, shown_lines: int, total_lines: int, kept: int, total: int, inside_line: int | None, continuation: str, ends_at_eof: bool = True) -> str:
|
||||
"""Marker appended to a truncated read_file result.
|
||||
|
||||
Line numbers are file line numbers: ``line_offset`` is the number of file
|
||||
lines before the first line of ``output`` (``start_line - 1`` for a ranged
|
||||
read), so a continuation named here can be passed straight back to
|
||||
``read_file``. ``inside_line`` is None when the kept text ends on a line
|
||||
boundary; otherwise it is the 1-based line of ``output`` the cut fell in
|
||||
and ``continuation`` says how to go on from there ("next", "whole_line" or
|
||||
"bash"). ``ends_at_eof`` is False when ``output`` is a bounded slice that
|
||||
may stop before the end of the file, so a line after its last line can
|
||||
still be named. The continuation is stated in lines because that is what
|
||||
``start_line`` and ``end_line`` take; character counts are kept for
|
||||
reference.
|
||||
"""
|
||||
first = line_offset + 1
|
||||
span = f"of {total_lines}" if line_offset == 0 else f"of {first}-{line_offset + total_lines}"
|
||||
if inside_line is None:
|
||||
shown = f"first {shown_lines}" if line_offset == 0 else f"lines {first}-{line_offset + shown_lines}"
|
||||
return f"... [truncated: showing {shown} {span} lines ({kept} of {total} chars). Continue with start_line={line_offset + shown_lines + 1}, or use start_line/end_line to read a specific range] ..."
|
||||
line = line_offset + inside_line
|
||||
head = f"\n... [truncated: showing first {kept} of {total} chars, cut inside line {line} {span} lines"
|
||||
if continuation == "next":
|
||||
return f"{head}. Continue with start_line={line}, or use start_line/end_line to read a specific range] ..."
|
||||
if continuation == "whole_line":
|
||||
# The line is longer than a read that also has to carry a marker, but
|
||||
# a read of that line alone comes back whole. After the last line of a
|
||||
# read that reached the end of the file there is nothing to name; a
|
||||
# bounded slice may stop mid-file, and a read one past the end only
|
||||
# answers that the line does not exist.
|
||||
after = f", then continue with start_line={line + 1}" if inside_line < total_lines or not ends_at_eof else ""
|
||||
return f"{head}. Read that line whole with start_line={line}, end_line={line}{after}] ..."
|
||||
return f"{head}; that line is longer than a read can return. Use bash (for example cut -c) to read the rest of that line, or start_line/end_line for other lines] ..."
|
||||
|
||||
|
||||
# Digits assumed when estimating the marker a follow-up read will carry. The
|
||||
# follow-up may span more of the file than the current read did, so its counts
|
||||
# are unknown here; over-reserving by a few characters only makes the "fits a
|
||||
# fresh read" decision more cautious.
|
||||
_READ_FILE_PESSIMISTIC_COUNT = 999_999_999
|
||||
|
||||
|
||||
def _read_file_marker_reserve(*, line_offset: int, total_lines: int, total: int) -> int:
|
||||
"""Longest marker any form can produce for these bounds (every field at its maximum)."""
|
||||
forms = [
|
||||
dict(shown_lines=total_lines, inside_line=None, continuation="next"),
|
||||
dict(shown_lines=0, inside_line=total_lines, continuation="next"),
|
||||
dict(shown_lines=0, inside_line=total_lines, continuation="whole_line"),
|
||||
dict(shown_lines=0, inside_line=total_lines, continuation="bash"),
|
||||
]
|
||||
return max(len(_read_file_truncation_marker(line_offset=line_offset, total_lines=total_lines, kept=total, total=total, **form)) for form in forms)
|
||||
|
||||
|
||||
# Emitted when the budget cannot even hold a marker: the model must still
|
||||
# learn the output was cut and how to read it in pieces.
|
||||
_READ_FILE_TINY_BUDGET_MARKER = "... [truncated: {total} chars exceed the {max_chars}-char read limit; use start_line/end_line to read a smaller range] ..."
|
||||
|
||||
|
||||
def _truncate_read_file_output(output: str, max_chars: int, *, line_offset: int = 0, joined_lines: bool = False, ends_at_eof: bool = True) -> str:
|
||||
"""Head-truncate read_file output, preserving the beginning of the file.
|
||||
|
||||
Source code and documents are read top-to-bottom; the head contains the
|
||||
most context (imports, class definitions, function signatures).
|
||||
|
||||
The cut lands on the last line boundary the budget allows, so the kept
|
||||
text ends with a complete line and the marker names the next
|
||||
``start_line`` in file line numbers (``line_offset`` is the number of file
|
||||
lines before ``output``, i.e. ``start_line - 1`` of a ranged read;
|
||||
``joined_lines`` says the output is a provider slice of lines joined with
|
||||
newlines, where a trailing newline is an empty last line rather than a
|
||||
line terminator; ``ends_at_eof`` is False for a slice bounded by an
|
||||
``end_line`` that may stop before the end of the file). Only when the
|
||||
line at the cut is longer than
|
||||
``_READ_FILE_LINE_CUT_SLACK`` does
|
||||
the cut stay at the character limit; the marker then names the line it
|
||||
fell inside and a continuation that is guaranteed to make progress: a
|
||||
read from that line when the whole line fits such a read, a single-line
|
||||
read when only the line alone fits, and bash when even that cannot return
|
||||
it.
|
||||
|
||||
The returned string (including the truncation marker) is guaranteed to be
|
||||
no longer than max_chars characters. Pass max_chars=0 to disable truncation
|
||||
and return the full output unchanged.
|
||||
|
||||
The marker reports the 1-indexed line the character cut lands in, so the
|
||||
model can resume with ``start_line`` without re-reading or skipping
|
||||
content (#5475). ``line_offset`` is the 0-based absolute line number of
|
||||
``output``'s first line — ranged reads pass the requested ``start_line -
|
||||
1`` so the reported lines are absolute file lines, keeping the resume
|
||||
point from looping back onto the slice's own beginning.
|
||||
"""
|
||||
if max_chars == 0:
|
||||
return output
|
||||
if len(output) <= max_chars:
|
||||
return output
|
||||
total = len(output)
|
||||
total_lines = output.count("\n") + (0 if output.endswith("\n") else 1)
|
||||
# Compute the exact worst-case marker length: every numeric field is at
|
||||
# its maximum (total + offset), so this is a tight upper bound.
|
||||
bound = total + max(line_offset, 0)
|
||||
marker_max_len = len(f"\n... [truncated: showing first {total} of {total} chars (cut lands in line {bound} of {bound}). Use start_line={bound} — optionally with end_line — to continue without a gap] ...")
|
||||
kept = max(0, max_chars - marker_max_len)
|
||||
total_lines = output.count("\n") + (1 if joined_lines or not output.endswith("\n") else 0)
|
||||
# Reserve the longest marker plus one character, so a newline sitting
|
||||
# exactly at the budget can still be kept as a complete line.
|
||||
kept = max(0, max_chars - _read_file_marker_reserve(line_offset=line_offset, total_lines=total_lines, total=total) - 1)
|
||||
if kept == 0:
|
||||
return output[:max_chars]
|
||||
# 1-indexed line holding the first hidden character: a cut mid-line lands
|
||||
# in the partially shown line, a cut exactly after a newline lands in the
|
||||
# next line — either way resuming at this line leaves no gap. Expressed in
|
||||
# absolute file lines via line_offset.
|
||||
cut_line = line_offset + output[:kept].count("\n") + 1
|
||||
last_line = line_offset + total_lines
|
||||
marker = f"\n... [truncated: showing first {kept} of {total} chars (cut lands in line {cut_line} of {last_line}). Use start_line={cut_line} — optionally with end_line — to continue without a gap] ..."
|
||||
# Too small a budget for any text plus a marker: say so instead of
|
||||
# returning a bare prefix that reads as the whole file.
|
||||
return _READ_FILE_TINY_BUDGET_MARKER.format(total=total, max_chars=max_chars)[:max_chars]
|
||||
boundary = output.rfind("\n", 0, kept + 1)
|
||||
if boundary != -1 and kept - (boundary + 1) <= _READ_FILE_LINE_CUT_SLACK:
|
||||
kept = boundary + 1
|
||||
marker = _read_file_truncation_marker(line_offset=line_offset, shown_lines=output[:kept].count("\n"), total_lines=total_lines, kept=kept, total=total, inside_line=None, continuation="next")
|
||||
return f"{output[:kept]}{marker}"
|
||||
line_start = boundary + 1
|
||||
line_end = output.find("\n", kept)
|
||||
line_len = (total if line_end == -1 else line_end) - line_start
|
||||
inside_line = output[:kept].count("\n") + 1
|
||||
# What a read starting at this line could keep, estimated pessimistically:
|
||||
# its marker may carry larger counts than this read's.
|
||||
next_kept = max_chars - _read_file_marker_reserve(line_offset=line_offset + inside_line - 1, total_lines=_READ_FILE_PESSIMISTIC_COUNT, total=_READ_FILE_PESSIMISTIC_COUNT) - 1
|
||||
if line_len <= next_kept:
|
||||
continuation = "next"
|
||||
elif line_len <= max_chars:
|
||||
continuation = "whole_line"
|
||||
else:
|
||||
continuation = "bash"
|
||||
marker = _read_file_truncation_marker(line_offset=line_offset, shown_lines=0, total_lines=total_lines, kept=kept, total=total, inside_line=inside_line, continuation=continuation, ends_at_eof=ends_at_eof)
|
||||
return f"{output[:kept]}{marker}"
|
||||
|
||||
|
||||
@ -2414,6 +2502,15 @@ def read_current_file_content(runtime: Runtime | None, path: str) -> str:
|
||||
return _read_file_from_sandbox(runtime, path)
|
||||
|
||||
|
||||
def _ranged_read_hits_a_line(runtime: Runtime | None, path: str, line: int) -> bool:
|
||||
"""Whether ``line`` exists, given that a ranged read of it came back empty.
|
||||
|
||||
Providers join selected lines with newlines, so reading the previous line
|
||||
together with this one yields a newline only if this line exists.
|
||||
"""
|
||||
return "\n" in _read_file_from_sandbox(runtime, path, start_line=line - 1, end_line=line)
|
||||
|
||||
|
||||
@tool("read_file", parse_docstring=True)
|
||||
def read_file_tool(
|
||||
runtime: Runtime,
|
||||
@ -2451,6 +2548,11 @@ def read_file_tool(
|
||||
content = read_current_file_content(runtime, path)
|
||||
if not content:
|
||||
if start_line is not None and start_line > 1:
|
||||
# A blank line and a line past the end both read back as "";
|
||||
# tell them apart so a continuation named by a truncation
|
||||
# marker is not reported as beyond the file.
|
||||
if _ranged_read_hits_a_line(runtime, path, start_line):
|
||||
return "(empty)"
|
||||
return "(start_line exceeds file length)"
|
||||
return "(empty)"
|
||||
try:
|
||||
@ -2460,10 +2562,8 @@ def read_file_tool(
|
||||
max_chars = sandbox_cfg.read_file_output_max_chars if sandbox_cfg else 50000
|
||||
except Exception:
|
||||
max_chars = 50000
|
||||
# Ranged reads hand a slice to the truncator; line_offset converts the
|
||||
# slice-relative cut line back to the absolute file lines the tool's
|
||||
# start_line/end_line arguments speak in (#5475).
|
||||
return _truncate_read_file_output(content, max_chars, line_offset=effective_start - 1)
|
||||
# Line numbers in the marker are file line numbers, so a ranged read passes its offset along.
|
||||
return _truncate_read_file_output(content, max_chars, line_offset=effective_start - 1, joined_lines=use_line_range, ends_at_eof=end_line is None)
|
||||
except SandboxError as e:
|
||||
return f"Error: {e}"
|
||||
except FileNotFoundError:
|
||||
|
||||
201
backend/tests/test_read_file_truncation_continuation.py
Normal file
201
backend/tests/test_read_file_truncation_continuation.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""Following the truncation marker's start_line reads a long file end to end.
|
||||
|
||||
Pins the real contract through ``read_file_tool`` and ``LocalSandbox.read_file``:
|
||||
each truncated read names the next ``start_line`` in file line numbers, so the
|
||||
kept text of successive reads reproduces the file without a gap or an overlap,
|
||||
including the second hop, where the ranged read is itself truncated and the
|
||||
provider has renumbered its lines from 1.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
||||
from deerflow.sandbox.tools import read_file_tool
|
||||
|
||||
_CONTINUE = re.compile(r"Continue with start_line=(\d+)")
|
||||
_WHOLE_LINE = re.compile(r"Read that line whole with start_line=(\d+), end_line=(\d+)(?:, then continue with start_line=(\d+))?")
|
||||
|
||||
|
||||
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(runtime, **kwargs) -> str:
|
||||
return read_file_tool.func(runtime=runtime, description="read", path="/mnt/user-data/uploads/long.txt", **kwargs)
|
||||
|
||||
|
||||
def test_following_the_markers_reads_the_whole_file_without_gap_or_overlap(tmp_path, monkeypatch) -> None:
|
||||
runtime = _local_runtime(tmp_path)
|
||||
lines = [f"{i:05d} " + "x" * (50 + i % 7) for i in range(1, 2601)] # 2,600 lines of ~57 chars, > 150k chars
|
||||
content = "\n".join(lines) + "\n"
|
||||
(tmp_path / "uploads" / "long.txt").write_text(content, 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)
|
||||
|
||||
segments, starts, kwargs = [], [], {}
|
||||
for _hop in range(10):
|
||||
result = _read(runtime, **kwargs)
|
||||
assert len(result) <= 50000
|
||||
marker = result.find("... [truncated:")
|
||||
if marker == -1:
|
||||
segments.append(result)
|
||||
break
|
||||
kept = result[:marker]
|
||||
assert kept.endswith("\n"), "a cut on a line boundary ends with a complete line"
|
||||
segments.append(kept)
|
||||
match = _CONTINUE.search(result)
|
||||
assert match, result[marker:]
|
||||
start = int(match.group(1))
|
||||
assert start == sum(seg.count("\n") for seg in segments) + 1, "the named line is the first unread file line"
|
||||
assert not starts or start > starts[-1]
|
||||
starts.append(start)
|
||||
kwargs = {"start_line": start}
|
||||
else:
|
||||
raise AssertionError("did not reach the end of the file in 10 hops")
|
||||
|
||||
assert len(starts) >= 2, "the second hop is a ranged read that is itself truncated"
|
||||
assert "".join(segments).rstrip("\n") == content.rstrip("\n")
|
||||
|
||||
|
||||
def _follow_markers(runtime, content: str) -> tuple[str, list[str]]:
|
||||
"""Read the file the way a model following the markers would; return (reconstruction, marker forms seen)."""
|
||||
segments, forms, kwargs = [], [], {}
|
||||
for _hop in range(20):
|
||||
result = _read(runtime, **kwargs)
|
||||
assert len(result) <= 50000
|
||||
marker = result.find("... [truncated:")
|
||||
if marker == -1:
|
||||
segments.append(result if result.endswith("\n") else result + "\n")
|
||||
return "".join(segments), forms
|
||||
if "cut inside line" in result:
|
||||
# A fallback cut ends mid-line and its marker starts with a newline;
|
||||
# drop the partial line, the continuation re-reads it whole.
|
||||
kept = result[: result.find("\n... [truncated:")]
|
||||
kept = kept[: kept.rfind("\n") + 1]
|
||||
else:
|
||||
kept = result[:marker]
|
||||
segments.append(kept)
|
||||
whole = _WHOLE_LINE.search(result)
|
||||
if whole:
|
||||
forms.append("whole_line")
|
||||
first, last = int(whole.group(1)), int(whole.group(2))
|
||||
assert first == last == sum(seg.count("\n") for seg in segments) + 1
|
||||
line = _read(runtime, start_line=first, end_line=last)
|
||||
assert "... [truncated:" not in line, "a single-line read the marker promised came back truncated"
|
||||
segments.append(line + "\n")
|
||||
if whole.group(3) is None:
|
||||
return "".join(segments), forms # that line was the last one
|
||||
kwargs = {"start_line": int(whole.group(3))}
|
||||
continue
|
||||
match = _CONTINUE.search(result)
|
||||
if not match:
|
||||
forms.append("bash")
|
||||
return "".join(segments), forms
|
||||
forms.append("next")
|
||||
start = int(match.group(1))
|
||||
assert start == sum(seg.count("\n") for seg in segments) + 1, "the named line is the first unread file line"
|
||||
kwargs = {"start_line": start}
|
||||
raise AssertionError("did not finish in 20 hops")
|
||||
|
||||
|
||||
def test_long_lines_near_the_budget_are_followed_without_gap_or_overlap(tmp_path, monkeypatch) -> None:
|
||||
runtime = _local_runtime(tmp_path)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
||||
tail = "".join(f"{i:05d} tail line\n" for i in range(1, 3001))
|
||||
for length in (49600, 49743, 49750, 49760, 50000):
|
||||
content = "a\n" + "y" * length + "\n" + tail
|
||||
(tmp_path / "uploads" / "long.txt").write_text(content, encoding="utf-8")
|
||||
rebuilt, forms = _follow_markers(runtime, content)
|
||||
assert rebuilt == content, (length, forms)
|
||||
assert "bash" not in forms, (length, forms)
|
||||
|
||||
|
||||
def test_a_line_longer_than_max_chars_is_pointed_at_bash_not_at_a_read(tmp_path, monkeypatch) -> None:
|
||||
runtime = _local_runtime(tmp_path)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
||||
content = "a\n" + "y" * 50001 + "\n" + "".join(f"{i:05d} tail line\n" for i in range(1, 301))
|
||||
(tmp_path / "uploads" / "long.txt").write_text(content, encoding="utf-8")
|
||||
result = _read(runtime)
|
||||
assert "cut inside line 2 of 302 lines" in result
|
||||
assert "Continue with start_line" not in result and "Read that line whole" not in result
|
||||
assert "cut -c" in result
|
||||
# No read_file call can return that line whole, so bash is the only honest pointer.
|
||||
assert "... [truncated:" in _read(runtime, start_line=2, end_line=2)
|
||||
|
||||
|
||||
def test_a_ranged_read_ending_in_a_blank_line_reports_the_full_span(tmp_path, monkeypatch) -> None:
|
||||
runtime = _local_runtime(tmp_path)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
||||
content = "".join(f"{i:05d} " + "x" * 51 + "\n" for i in range(1, 3001)) + "\n" # 3,001 lines, the last one blank
|
||||
(tmp_path / "uploads" / "long.txt").write_text(content, encoding="utf-8")
|
||||
assert "of 3001 lines" in _read(runtime)
|
||||
assert "of 2-3001 lines" in _read(runtime, start_line=2)
|
||||
assert "of 3001 lines" in _read(runtime, start_line=1, end_line=3001)
|
||||
|
||||
|
||||
def test_a_last_line_read_whole_is_the_end_of_the_walk(tmp_path, monkeypatch) -> None:
|
||||
runtime = _local_runtime(tmp_path)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
||||
content = "a\n" + "y" * 50000 + "\n"
|
||||
(tmp_path / "uploads" / "long.txt").write_text(content, encoding="utf-8")
|
||||
rebuilt, forms = _follow_markers(runtime, content)
|
||||
assert forms == ["whole_line"]
|
||||
assert rebuilt == content
|
||||
|
||||
|
||||
def test_a_bounded_read_cut_inside_its_last_line_still_names_the_line_after_it(tmp_path, monkeypatch) -> None:
|
||||
runtime = _local_runtime(tmp_path)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
||||
lines = [f"{i:05d} line" for i in range(1, 1002)] + ["y" * 49900] + [f"{i:05d} after" for i in range(1, 301)]
|
||||
content = "\n".join(lines) + "\n"
|
||||
(tmp_path / "uploads" / "long.txt").write_text(content, encoding="utf-8")
|
||||
result = _read(runtime, start_line=1, end_line=1002)
|
||||
assert "cut inside line 1002 of 1002 lines" in result
|
||||
assert "Read that line whole with start_line=1002, end_line=1002, then continue with start_line=1003]" in result
|
||||
whole = _read(runtime, start_line=1002, end_line=1002)
|
||||
assert whole == "y" * 49900
|
||||
rest = _read(runtime, start_line=1003)
|
||||
assert "... [truncated:" not in rest and rest.startswith("00001 after")
|
||||
|
||||
|
||||
def test_a_start_line_only_read_cut_inside_the_files_last_line_names_nothing_further(tmp_path, monkeypatch) -> None:
|
||||
runtime = _local_runtime(tmp_path)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
||||
content = "a\n" + "x" * 40000 + "\n" + "y" * 49900 + "\n"
|
||||
(tmp_path / "uploads" / "long.txt").write_text(content, encoding="utf-8")
|
||||
result = _read(runtime, start_line=2) # no end_line: the read runs to the end of the file
|
||||
assert "cut inside line 3 of 2-3 lines" in result
|
||||
assert "Read that line whole with start_line=3, end_line=3]" in result
|
||||
assert "then continue" not in result
|
||||
|
||||
|
||||
def test_a_blank_line_named_by_a_marker_reads_as_empty_not_as_past_the_end(tmp_path, monkeypatch) -> None:
|
||||
runtime = _local_runtime(tmp_path)
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox("t1"))
|
||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_thread_directories_exist", lambda runtime: None)
|
||||
lines = [f"{i:05d} line" for i in range(1, 1099)] + ["y" * 49800, ""] + [f"{i:05d} after" for i in range(1, 401)]
|
||||
content = "\n".join(lines) + "\n" # line 1100 is blank, 400 lines follow it
|
||||
(tmp_path / "uploads" / "long.txt").write_text(content, encoding="utf-8")
|
||||
result = _read(runtime, start_line=958, end_line=1100)
|
||||
assert "Read that line whole with start_line=1099, end_line=1099, then continue with start_line=1100]" in result
|
||||
assert _read(runtime, start_line=1100, end_line=1100) == "(empty)"
|
||||
assert _read(runtime, start_line=1100).startswith("\n00001 after")
|
||||
assert _read(runtime, start_line=2000) == "(start_line exceeds file length)"
|
||||
@ -13,9 +13,13 @@ from deerflow.sandbox.tools import _truncate_bash_output, _truncate_ls_output, _
|
||||
|
||||
def _head_and_marker(result: str) -> tuple[str, str]:
|
||||
"""Split a truncated read_file result into shown head and trailing marker."""
|
||||
idx = result.rfind("\n... [truncated:")
|
||||
idx = result.rfind("... [truncated:")
|
||||
assert idx != -1, "truncation marker missing"
|
||||
return result[:idx], result[idx:]
|
||||
marker = result[idx:]
|
||||
# Complete-line cuts keep the source newline; character cuts insert one
|
||||
# solely to separate the marker from the partial source line.
|
||||
head_end = idx - 1 if "cut inside line" in marker else idx
|
||||
return result[:head_end], marker
|
||||
|
||||
|
||||
def _line_containing(output: str, char_index: int) -> int:
|
||||
@ -214,15 +218,14 @@ class TestTruncateReadFileOutput:
|
||||
assert "end_line" in result
|
||||
|
||||
def test_marker_reports_the_line_the_cut_lands_in(self):
|
||||
# Shape from #5475: a multi-line file cut mid-line, where the old
|
||||
# marker gave only a character count so the model could not compute
|
||||
# which line to resume from.
|
||||
# Shape from #5475: a multi-line file whose old marker gave only a
|
||||
# character count, leaving the model to compute the resume line.
|
||||
output = "".join(f"def fn_{i}():\n return {i}\n" for i in range(3000))
|
||||
result = _truncate_read_file_output(output, 50000)
|
||||
head, marker = _head_and_marker(result)
|
||||
cut_line = int(re.search(r"cut lands in line (\d+) of", marker).group(1))
|
||||
cut_line = int(re.search(r"Continue with start_line=(\d+)", marker).group(1))
|
||||
total_lines = output.count("\n")
|
||||
assert f"cut lands in line {cut_line} of {total_lines}" in marker
|
||||
assert f"of {total_lines} lines" in marker
|
||||
assert f"start_line={cut_line}" in marker
|
||||
# The no-gap contract: the reported line is the one holding the first
|
||||
# hidden character (computed independently from line spans).
|
||||
@ -237,7 +240,7 @@ class TestTruncateReadFileOutput:
|
||||
for max_chars in [1000, 5000, 20000, 50000]:
|
||||
result = _truncate_read_file_output(output, max_chars)
|
||||
head, marker = _head_and_marker(result)
|
||||
cut_line = int(re.search(r"cut lands in line (\d+) of", marker).group(1))
|
||||
cut_line = int(re.search(r"Continue with start_line=(\d+)", marker).group(1))
|
||||
assert 1 <= cut_line <= len(lines)
|
||||
assert _line_containing(output, len(head)) + 1 == cut_line
|
||||
assert f"start_line={cut_line}" in marker
|
||||
@ -260,9 +263,9 @@ class TestTruncateReadFileOutput:
|
||||
offset = 830 # slice starts at absolute line 831
|
||||
result = _truncate_read_file_output(slice_output, 50000, line_offset=offset)
|
||||
head, marker = _head_and_marker(result)
|
||||
absolute_cut = offset + head.count("\n") + 1
|
||||
absolute_cut = offset + _line_containing(slice_output, len(head)) + 1
|
||||
absolute_last = offset + len(slice_lines)
|
||||
assert f"cut lands in line {absolute_cut} of {absolute_last}" in marker
|
||||
assert f"of {offset + 1}-{absolute_last} lines" in marker
|
||||
assert f"start_line={absolute_cut}" in marker
|
||||
assert absolute_cut > offset + 1 # resume strictly advances
|
||||
|
||||
@ -274,6 +277,159 @@ class TestTruncateReadFileOutput:
|
||||
output = "X" * 100000
|
||||
assert _truncate_read_file_output(output, 0) == output
|
||||
|
||||
def test_cut_lands_on_a_line_boundary_and_names_the_next_line(self):
|
||||
lines = [f"line {i} " + "y" * (i % 50) for i in range(1, 3001)]
|
||||
output = "\n".join(lines) + "\n"
|
||||
result = _truncate_read_file_output(output, 50000)
|
||||
kept_text = result[: result.index("... [truncated:")]
|
||||
assert kept_text.endswith("\n")
|
||||
shown = kept_text.count("\n")
|
||||
assert kept_text == "\n".join(lines[:shown]) + "\n"
|
||||
assert f"showing first {shown} of 3000 lines" in result
|
||||
assert f"({len(kept_text)} of {len(output)} chars)" in result
|
||||
assert f"Continue with start_line={shown + 1}" in result
|
||||
assert "start_line/end_line" in result
|
||||
assert len(result) <= 50000
|
||||
|
||||
def test_next_start_line_reads_the_rest_without_gap_or_overlap(self):
|
||||
lines = [f"line {i} " + "y" * (i % 50) for i in range(1, 3001)]
|
||||
output = "\n".join(lines) + "\n"
|
||||
result = _truncate_read_file_output(output, 50000)
|
||||
kept_text = result[: result.index("... [truncated:")]
|
||||
shown = kept_text.count("\n")
|
||||
# What read_file(start_line=shown + 1) returns is exactly the unread remainder.
|
||||
assert output[len(kept_text) :] == "\n".join(lines[shown:]) + "\n"
|
||||
|
||||
def test_long_line_at_the_cut_falls_back_to_a_char_cut_that_names_the_line(self):
|
||||
output = "a\nb\n" + "X" * 60000
|
||||
result = _truncate_read_file_output(output, 50000)
|
||||
assert result.startswith("a\nb\nXXXX")
|
||||
assert result.count("X") > 49000 # the line boundary at char 4 is not used: it would drop the whole budget
|
||||
assert f"of {len(output)} chars" in result
|
||||
assert "cut inside line 3 of 3 lines" in result
|
||||
# Re-reading line 3 could only be truncated to the same head, so it is not offered as the continuation.
|
||||
assert "Continue with start_line" not in result
|
||||
assert "longer than a read can return" in result and "cut -c" in result
|
||||
assert "start_line/end_line" in result
|
||||
assert len(result) <= 50000
|
||||
|
||||
def test_fallback_names_the_line_when_it_fits_a_fresh_read(self):
|
||||
lines = [f"line {i}" for i in range(1, 2001)]
|
||||
lines[1499] = "L" * 6000 # a long line at the cut, but one a fresh read can return whole
|
||||
output = "\n".join(lines) + "\n"
|
||||
# The cut lands about 5,000 chars into the long line: past the 4,096-char slack, so the
|
||||
# boundary is not used, while the whole 6,000-char line still fits a fresh read.
|
||||
max_chars = len("\n".join(lines[:1499])) + 1 + 5000 + 300
|
||||
result = _truncate_read_file_output(output, max_chars)
|
||||
assert "cut inside line 1500 of 2000 lines" in result
|
||||
assert "Continue with start_line=1500" in result
|
||||
assert len(result) <= max_chars
|
||||
|
||||
def test_ranged_read_marker_uses_file_line_numbers(self):
|
||||
lines = [f"line {i} " + "y" * (i % 50) for i in range(1, 3001)]
|
||||
# A ranged read from line 744 returns the file's lines 744.., renumbered from 1 by the provider.
|
||||
output = "\n".join(lines[743:]) + "\n"
|
||||
result = _truncate_read_file_output(output, 50000, line_offset=743)
|
||||
kept_text = result[: result.index("... [truncated:")]
|
||||
shown = kept_text.count("\n")
|
||||
assert f"showing lines 744-{743 + shown} of 744-3000 lines" in result
|
||||
assert f"Continue with start_line={743 + shown + 1}" in result
|
||||
assert "showing first" not in result
|
||||
|
||||
def test_ranged_read_fallback_names_the_file_line(self):
|
||||
output = "a\nb\n" + "X" * 60000
|
||||
result = _truncate_read_file_output(output, 50000, line_offset=10)
|
||||
assert "cut inside line 13 of 11-13 lines" in result
|
||||
|
||||
def test_a_line_exactly_as_long_as_the_budget_is_kept_as_a_complete_line(self):
|
||||
# Sweep budgets around the marker size so that for some max_chars the
|
||||
# first line's newline sits exactly at the char budget. A named
|
||||
# continuation must always move past line 1; naming line 1 again would
|
||||
# send the model in a circle.
|
||||
output = "\n".join("x" * 40 for _ in range(100)) + "\n"
|
||||
seen_boundary_at_40 = False
|
||||
for max_chars in range(200, 420):
|
||||
result = _truncate_read_file_output(output, max_chars)
|
||||
if result == output:
|
||||
continue
|
||||
assert len(result) <= max_chars
|
||||
marker = result.find("... [truncated:")
|
||||
assert output.startswith(result[:marker].rstrip("\n"))
|
||||
match = re.search(r"Continue with start_line=(\d+)", result)
|
||||
if match and "Read that line whole" not in result:
|
||||
assert int(match.group(1)) >= 2, (max_chars, result[marker:])
|
||||
if result[:marker] == "x" * 40 + "\n":
|
||||
seen_boundary_at_40 = True
|
||||
assert "showing first 1 of 100 lines" in result
|
||||
assert "Continue with start_line=2" in result
|
||||
assert seen_boundary_at_40
|
||||
|
||||
def test_a_ranged_read_never_names_its_own_first_line_as_the_continuation(self):
|
||||
output = "\n".join("x" * 40 for _ in range(100)) + "\n" # the provider's slice for start_line=2
|
||||
for max_chars in range(200, 420):
|
||||
result = _truncate_read_file_output(output, max_chars, line_offset=1)
|
||||
if result == output:
|
||||
continue
|
||||
match = re.search(r"Continue with start_line=(\d+)", result)
|
||||
if match and "Read that line whole" not in result:
|
||||
assert int(match.group(1)) >= 3, (max_chars, result[result.find("... [truncated:") :])
|
||||
|
||||
def test_fallback_offers_a_single_line_read_when_only_the_line_alone_fits(self):
|
||||
tail = "".join(f"tail {i}\n" for i in range(200))
|
||||
# Longer than what a read carrying a marker keeps, but not longer than max_chars:
|
||||
# read_file(start_line=2, end_line=2) returns it whole.
|
||||
output = "a\n" + "y" * 49900 + "\n" + tail
|
||||
result = _truncate_read_file_output(output, 50000)
|
||||
assert "cut inside line 2 of 202 lines" in result
|
||||
assert "Read that line whole with start_line=2, end_line=2, then continue with start_line=3" in result
|
||||
assert _truncate_read_file_output("y" * 49900, 50000) == "y" * 49900
|
||||
# Longer than max_chars: no read_file call can return it, so bash is the only honest pointer.
|
||||
output = "a\n" + "y" * 50001 + "\n" + tail
|
||||
result = _truncate_read_file_output(output, 50000)
|
||||
assert "Continue with start_line" not in result and "Read that line whole" not in result
|
||||
assert "longer than a read can return" in result and "cut -c" in result
|
||||
|
||||
def test_a_budget_too_small_for_any_marker_still_says_the_output_was_cut(self):
|
||||
output = "".join(f"{i:04d} " + "x" * 55 + "\n" for i in range(1, 1001))
|
||||
for max_chars in (10, 60, 150, 238):
|
||||
result = _truncate_read_file_output(output, max_chars)
|
||||
assert len(result) <= max_chars
|
||||
assert result.startswith("... [truncated:"[:max_chars])
|
||||
assert not result.startswith("0001")
|
||||
|
||||
def test_a_joined_slice_counts_an_empty_last_line(self):
|
||||
# Providers return a ranged read as lines joined with newlines, so a
|
||||
# trailing newline there is an empty last line, not a terminator.
|
||||
lines = [f"line {i} " + "y" * (i % 50) for i in range(1, 3000)] + [""]
|
||||
output = "\n".join(lines) # 3000 lines ending with a blank one
|
||||
result = _truncate_read_file_output(output, 50000, line_offset=1, joined_lines=True)
|
||||
assert "of 2-3001 lines" in result
|
||||
result = _truncate_read_file_output(output + "\n", 50000)
|
||||
assert "of 3000 lines" in result # a whole-file read: the trailing newline terminates the last line
|
||||
|
||||
def test_single_line_read_form_names_no_continuation_after_the_last_line(self):
|
||||
result = _truncate_read_file_output("a\n" + "y" * 50000, 50000)
|
||||
assert "Read that line whole with start_line=2, end_line=2]" in result
|
||||
assert "then continue" not in result
|
||||
result = _truncate_read_file_output("a\n" + "y" * 50000 + "\nz\n", 50000)
|
||||
assert "Read that line whole with start_line=2, end_line=2, then continue with start_line=3]" in result
|
||||
|
||||
def test_single_line_read_form_keeps_naming_the_next_line_for_a_bounded_slice(self):
|
||||
# A ranged read's slice may stop before the end of the file (an
|
||||
# end_line below its length), so the line after its last line can
|
||||
# still exist; naming it costs at most a harmless "exceeds file length".
|
||||
result = _truncate_read_file_output("a\n" + "y" * 50000, 50000, line_offset=1000, joined_lines=True, ends_at_eof=False)
|
||||
assert "Read that line whole with start_line=1002, end_line=1002, then continue with start_line=1003]" in result
|
||||
# A start_line-only read runs to the end of the file, so its last line is the file's last line.
|
||||
result = _truncate_read_file_output("a\n" + "y" * 50000, 50000, line_offset=1000, joined_lines=True, ends_at_eof=True)
|
||||
assert "Read that line whole with start_line=1002, end_line=1002]" in result
|
||||
|
||||
def test_file_without_trailing_newline_counts_its_last_line(self):
|
||||
lines = [f"line {i} " + "y" * (i % 50) for i in range(1, 3001)]
|
||||
output = "\n".join(lines)
|
||||
result = _truncate_read_file_output(output, 50000)
|
||||
assert "of 3000 lines" in result
|
||||
|
||||
def test_tail_is_not_preserved(self):
|
||||
# head-truncation: tail should be cut off
|
||||
output = "H" * 50000 + "TAIL_SHOULD_NOT_APPEAR"
|
||||
|
||||
@ -1453,6 +1453,8 @@ sandbox:
|
||||
# Tool output truncation limits (characters).
|
||||
# bash uses middle-truncation (head + tail) since errors can appear anywhere in the output.
|
||||
# read_file and ls use head-truncation since their content is front-loaded.
|
||||
# A read_file cut lands on a line boundary when the budget allows, and the marker names
|
||||
# the next start_line to continue with, so the agent can page through a long file exactly.
|
||||
# Set to 0 to disable truncation.
|
||||
bash_output_max_chars: 20000
|
||||
read_file_output_max_chars: 50000
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user