fix(tenki): honour start_line and end_line in read_file (#5616)

* fix(tenki): honour start_line and end_line in read_file

The base Sandbox contract declares read_file(path, start_line, end_line),
and the tools layer passes both keyword arguments on every ranged read —
including the continuation path that a truncated read names with its
start_line marker. TenkiSandbox.read_file accepted only path, so any
ranged read through a Tenki sandbox raised TypeError and surfaced as
"Unexpected error reading file"; a truncated read could not be continued.

Slice the text the way the other providers do (e2b, opensandbox,
boxlite): the full text when no range is given, otherwise the selected
lines joined with newlines.

* fix(tenki): clamp a negative start_line and pin the out-of-range read contract

Mirror LocalSandbox.read_file and clamp the start line to at least 1 so a
negative start_line cannot wrap around through Python's negative-index
slicing. Also extend the ranged-read test with the two boundary cases the
tools layer depends on: a start past EOF returns an empty string, and a
negative start reads from the first line.

* fix(tenki): clamp a negative end_line in read_file

Mirror LocalSandbox.read_file for the symmetric range boundary: clamp a
negative end_line to zero so Python's negative-index slicing cannot silently
drop the last line. Pin the empty result in the existing ranged-read test.
This commit is contained in:
FanouZeng-TT 2026-09-22 21:10:27 +08:00 committed by GitHub
parent 6db7e8091f
commit 43c32ade00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 33 additions and 2 deletions

View File

@ -282,13 +282,27 @@ class TenkiSandbox(Sandbox):
# ── file operations ───────────────────────────────────────────────── # ── file operations ─────────────────────────────────────────────────
def read_file(self, path: str) -> str: def read_file(
self,
path: str,
start_line: int | None = None,
end_line: int | None = None,
) -> str:
resolved = self._resolve_path(path) resolved = self._resolve_path(path)
try: try:
return self._fs_op(lambda fs: fs.read_text(resolved)) content = self._fs_op(lambda fs: fs.read_text(resolved))
except Exception as e: except Exception as e:
logger.error("read_file %s failed: %s", resolved, e) logger.error("read_file %s failed: %s", resolved, e)
return f"Error: {e}" return f"Error: {e}"
if start_line is None and end_line is None:
return content
lines = (content or "").splitlines()
# Clamp like LocalSandbox.read_file: a negative start would otherwise
# wrap around through Python's negative-index slicing instead of
# reading from the first line.
start = max(start_line or 1, 1)
end = max(end_line, 0) if end_line is not None else len(lines)
return "\n".join(lines[start - 1 : end])
def write_file(self, path: str, content: str, append: bool = False) -> None: def write_file(self, path: str, content: str, append: bool = False) -> None:
self._write_bytes(self._resolve_path(path), content.encode("utf-8"), append=append) self._write_bytes(self._resolve_path(path), content.encode("utf-8"), append=append)

View File

@ -514,6 +514,23 @@ def test_read_missing_file_returns_error() -> None:
assert box.read_file("/mnt/user-data/workspace/nope.txt").startswith("Error:") assert box.read_file("/mnt/user-data/workspace/nope.txt").startswith("Error:")
def test_read_file_supports_bounded_ranges() -> None:
"""The tools layer passes ``start_line``/``end_line`` on every ranged read."""
box = TenkiSandbox("sb", _FakeSandbox())
box.write_file("/mnt/user-data/workspace/range.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
assert box.read_file("/mnt/user-data/workspace/range.txt") == "line 1\nline 2\nline 3\nline 4\nline 5"
assert box.read_file("/mnt/user-data/workspace/range.txt", start_line=2, end_line=4) == "line 2\nline 3\nline 4"
assert box.read_file("/mnt/user-data/workspace/range.txt", start_line=4) == "line 4\nline 5"
assert box.read_file("/mnt/user-data/workspace/range.txt", end_line=2) == "line 1\nline 2"
# A start past EOF comes back empty rather than raising: the tool layer's
# "(start_line exceeds file length)" message and the truncated-read
# continuation path both depend on that contract.
assert box.read_file("/mnt/user-data/workspace/range.txt", start_line=99) == ""
# Negative bounds clamp like LocalSandbox instead of wrapping around.
assert box.read_file("/mnt/user-data/workspace/range.txt", start_line=-1) == ("line 1\nline 2\nline 3\nline 4\nline 5")
assert box.read_file("/mnt/user-data/workspace/range.txt", end_line=-1) == ""
def test_download_missing_file_raises_oserror() -> None: def test_download_missing_file_raises_oserror() -> None:
box = TenkiSandbox("sb", _FakeSandbox()) box = TenkiSandbox("sb", _FakeSandbox())
with pytest.raises(OSError): with pytest.raises(OSError):