diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 463656d4b..63c1c5237 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -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: diff --git a/backend/tests/test_read_file_truncation_continuation.py b/backend/tests/test_read_file_truncation_continuation.py new file mode 100644 index 000000000..a0a7bf86b --- /dev/null +++ b/backend/tests/test_read_file_truncation_continuation.py @@ -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)" diff --git a/backend/tests/test_tool_output_truncation.py b/backend/tests/test_tool_output_truncation.py index 1d4094ac4..c1a760cf9 100644 --- a/backend/tests/test_tool_output_truncation.py +++ b/backend/tests/test_tool_output_truncation.py @@ -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" diff --git a/config.example.yaml b/config.example.yaml index 2a13a6a1e..0653e5d3f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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