From 3df3b3c630922a76efe2f8358a01a464d6e4bf5a Mon Sep 17 00:00:00 2001 From: Totoro Date: Wed, 11 Nov 2026 11:08:39 +0800 Subject: [PATCH] feat(sandbox): show source line numbers for short read_file ranges (#8109) * 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> --- CHANGELOG.md | 4 + README.md | 2 + .../harness/deerflow/sandbox/AGENTS.md | 2 +- .../deerflow/sandbox/read_file_contract.py | 1 + .../harness/deerflow/sandbox/tools.py | 25 +++++ backend/tests/test_read_file_line_range.py | 94 +++++++++++++++++++ 6 files changed, 127 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b92d422160..d1bab5dba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ This section accumulates work toward the **2.3.0** milestone ([2.3.0](https://github.com/bytedance/deer-flow/milestone/4)). ### Added +- **sandbox:** Short ranged `read_file` calls on uploaded and workspace files + display source line numbers when the result contains 1–40 lines and the + labeled view fits the output budget. ([#8109](https://github.com/bytedance/deer-flow/pull/8109)) + - **hitl:** Optional human-in-the-loop approval for high-risk tool calls. `hitl_approval.tools` (host-owned) pauses matching calls via LangGraph `interrupt()`; the run enters `waiting_for_input` and the Web UI shows an diff --git a/README.md b/README.md index e8a097d218..b5ef088972 100644 --- a/README.md +++ b/README.md @@ -3275,6 +3275,8 @@ Loop warnings let agents save collected results before their final answer. If th Ranged `read_file` calls count lines the same way on every sandbox provider: a line ends only at a newline. On E2B, BoxLite, Tenki, and OpenSandbox, a file with bare carriage returns (such as a saved progress log), form feeds, or Unicode line separators therefore returns the same lines as on the local sandbox, and the `start_line` a truncated read suggests points at the next unread line. +For agent tool calls, `read_file` adds source line numbers when a range in an uploaded or workspace file returns 1–40 lines and the labeled view fits the output budget. Copy the text after the first ` | ` on each line for exact edits or writes. Write tools preserve submitted text, so copying the labeled display into a write stores those labels as file content. + Concurrent reads and writes to the same file share a gate across synchronous and asynchronous tool calls. Async callers waiting for that gate do not occupy worker threads needed to finish the current read or write. Cancelling a waiting call leaves the current operation running; a call that already started file inspection still waits for that work to finish before releasing its gate. The built-in `grep` tool searches either one text file or all matching text files below a directory, so an agent can search an uploaded document directly without first broadening the request to the entire uploads directory. diff --git a/backend/packages/harness/deerflow/sandbox/AGENTS.md b/backend/packages/harness/deerflow/sandbox/AGENTS.md index 0ffe5aa4c4..60d8af2fe6 100644 --- a/backend/packages/harness/deerflow/sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/sandbox/AGENTS.md @@ -148,7 +148,7 @@ symlinks, and visible depth/size bounds. - `ls` - Directory listing (tree format, max 2 levels). Remote commands precheck root existence and emit `__DF_FIND_STATUS__:missing`; `find` status 1 is always an incomplete-traversal `OSError`. `AioSandbox.list_dir` parses only `completed`/`None` on #5634's selected generation: `hard_timeout` raises `TimeoutError` and keeps that generation reusable; an ambiguous timeout or status raises `OSError`, replays nothing, and fences that generation. Missing-session 404s drop the stale id without replay. Persistent/scoped shells fence all `httpx.TransportError`s before cleanup. - `glob` - Find files or directories below a root directory with bounded results - `grep` - Search one text file or recursively search a directory, with optional glob filtering and bounded line-level results -- `read_file` - Read file contents with optional line range; full reads keep stored line endings (Local `read_file`/`grep` read `newline="\n"`, writes `newline=""`), ranged reads join with `\n`; remote providers slice with `split_file_lines`, not `splitlines()`. Stamp `deerflow_tool_meta` from execution, including async init errors; never file prose. No-ID Python calls retain strings. +- `read_file` - Read file contents with optional line range; full reads keep stored line endings (Local `read_file`/`grep` read `newline="\n"`, writes `newline=""`), ranged reads join with `\n`; remote providers slice with `split_file_lines`, not `splitlines()`. Stamp `deerflow_tool_meta` from execution, including async init errors; never file prose. No-ID Python calls retain strings. Model-facing ranges of 1–40 upload/workspace lines can display source numbers within the output budget (`READ_FILE_NUMBERED_MAX_LINES`). Keep the tool description and README aligned with this limit. Write tools accept literal numbered text, so the model must copy only the text after the first ` | ` for edits or writes. Preserve in-band errors and range markers. - `write_file` - Create/overwrite text, or append with `append=True`. Enforces read-before-write when enabled. Reject exact elision placeholders before sync/async sandbox initialization; [matching rules](../agents/middlewares/TOOL_OUTPUT_PERSISTENCE.md#model-bound-write-elision). - `str_replace` - Substring replacement (single or all occurrences); `_match_line_endings` gives `old_str`/`new_str` a CRLF file's endings; same-path serialization is scoped to `(sandbox.id, path)` so isolated sandboxes do not contend on identical virtual paths inside one process; subject to the read-before-write gate when `read_before_write.enabled` (see Middleware Chain) diff --git a/backend/packages/harness/deerflow/sandbox/read_file_contract.py b/backend/packages/harness/deerflow/sandbox/read_file_contract.py index be770ca344..7d4396bcb9 100644 --- a/backend/packages/harness/deerflow/sandbox/read_file_contract.py +++ b/backend/packages/harness/deerflow/sandbox/read_file_contract.py @@ -17,6 +17,7 @@ READ_FILE_NO_CONTENT_RESULTS = frozenset( } ) READ_FILE_TRUNCATION_PREFIX = "... [truncated:" +READ_FILE_NUMBERED_MAX_LINES = 40 def count_file_lines(content: str) -> int: diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index e8030eb7e4..93abead000 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -54,6 +54,8 @@ from deerflow.sandbox.read_file_contract import ( READ_FILE_INVALID_END_LINE, READ_FILE_INVALID_RANGE, READ_FILE_INVALID_START_LINE, + READ_FILE_NO_CONTENT_RESULTS, + READ_FILE_NUMBERED_MAX_LINES, READ_FILE_START_LINE_EXCEEDS, READ_FILE_TRUNCATION_PREFIX, ) @@ -3402,6 +3404,9 @@ def read_file_tool( ) -> str | ToolMessage: """Read the contents of a text file. Use this to examine source code, configuration files, logs, or any text-based file. + Ranges returning 1-40 lines from uploads or workspace files show source line labels when the labeled output fits the budget. + For edits or writes, copy only the text after the first ' | ' on each labeled line. + Args: path: The **absolute** path to the file to read. description: Optional short explanation of this read shown in the UI. @@ -3446,6 +3451,26 @@ def read_file_tool( max_chars = 50000 # Line numbers in the marker are file line numbers, so a ranged read passes its offset along. output = _truncate_read_file_output(content, max_chars, line_offset=effective_start - 1, joined_lines=use_line_range, ends_at_eof=end_line is None) + call_id = getattr(runtime, "tool_call_id", None) + # Leave in-band provider failures and range markers recognizable to consumers. + if ( + use_line_range + and isinstance(call_id, str) + and call_id + and requested_path.startswith((f"{VIRTUAL_PATH_PREFIX}/uploads/", f"{VIRTUAL_PATH_PREFIX}/workspace/")) + and output == content + and not content.startswith("Error:") + and content.strip() not in READ_FILE_NO_CONTENT_RESULTS + ): + # A provider's ranged result joins source lines, including a trailing empty line. + lines = content.split("\n") + if 1 <= len(lines) <= READ_FILE_NUMBERED_MAX_LINES and (end_line is None or len(lines) <= end_line - effective_start + 1): + rows = "\n".join(f"{number} | {line}" for number, line in enumerate(lines, start=effective_start)) + # Write tools accept literal 'N | ' text. A verbatim write-back of + # this display can therefore persist the labels as file content. + numbered = "Source lines (for exact edits, copy the text after the first ' | '):\n" + rows + if max_chars == 0 or len(numbered) <= max_chars: + output = numbered return _read_file_result(runtime, output, status="partial_success" if max_chars != 0 and len(content) > max_chars else "success") except SandboxError as e: return _read_file_failure(runtime, f"Error: {e}", e) diff --git a/backend/tests/test_read_file_line_range.py b/backend/tests/test_read_file_line_range.py index 0c86d78cdf..9f9d88a6c9 100644 --- a/backend/tests/test_read_file_line_range.py +++ b/backend/tests/test_read_file_line_range.py @@ -11,6 +11,8 @@ 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 @@ -121,3 +123,95 @@ def test_contract_constants_parity_with_localsandbox() -> None: 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