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>
This commit is contained in:
Totoro 2026-11-11 11:08:39 +08:00 committed by GitHub
parent bf4aa82f09
commit 3df3b3c630
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 127 additions and 1 deletions

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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