mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 14:00:35 +00:00
feat(sandbox): show source line numbers for short read_file ranges (#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:
parent
bf4aa82f09
commit
3df3b3c630
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user