Skip to content
Merged
13 changes: 1 addition & 12 deletions libs/deepagents/deepagents/backends/context_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
BackendProtocol,
DeleteResult,
EditResult,
FileData,
FileDownloadResponse,
FileInfo,
FileUploadResponse,
Expand Down Expand Up @@ -159,17 +158,7 @@ def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult
return ReadResult(error=f"File '{file_path}' not found")

file_data = create_file_data(content)
sliced = slice_read_response(file_data, offset, limit)
if isinstance(sliced, ReadResult):
return sliced
return ReadResult(
file_data=FileData(
content=sliced,
encoding=file_data.get("encoding", "utf-8"),
created_at=file_data.get("created_at", ""),
modified_at=file_data.get("modified_at", ""),
)
)
return slice_read_response(file_data, offset, limit)

def write(self, file_path: str, content: str) -> WriteResult:
"""Commit `content` to `file_path`."""
Expand Down
22 changes: 18 additions & 4 deletions libs/deepagents/deepagents/backends/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,10 @@ def read(
if fd >= 0:
os.close(fd)

total_lines: int | None = None
start_line: int | None = None
end_line: int | None = None
next_offset: int | None = None
if file_type == "text":
empty_msg = check_empty_content(content)
if empty_msg:
Expand All @@ -480,13 +484,23 @@ def read(
lines = content.splitlines(keepends=True)
start_idx = offset
end_idx = min(start_idx + limit, len(lines))
total_lines = len(lines)

if start_idx >= len(lines):
return ReadResult(error=f"Line offset {offset} exceeds file length ({len(lines)} lines)")
if start_idx >= total_lines:
return ReadResult(error=f"Line offset {offset} exceeds file length ({total_lines} lines)")

file_data = FileData(content="".join(lines[start_idx:end_idx]), encoding="utf-8")

return ReadResult(file_data=file_data)
start_line = start_idx + 1
end_line = end_idx
next_offset = end_idx if end_idx < total_lines else None

return ReadResult(
file_data=file_data,
total_lines=total_lines,
start_line=start_line,
end_line=end_line,
next_offset=next_offset,
)
except (OSError, UnicodeDecodeError) as e:
return ReadResult(error=f"Error reading file '{file_path}': {e}")

Expand Down
30 changes: 25 additions & 5 deletions libs/deepagents/deepagents/backends/langsmith.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,13 @@ def read( # noqa: PLR0911 - early returns for distinct error conditions
offset = int(offset)
limit = int(limit)

if not lines or offset >= len(lines):
return ReadResult(error=f"File '{file_path}': Line offset {offset} exceeds file length ({len(lines)} lines)")
total_lines = len(lines)
if not lines or offset >= total_lines:
return ReadResult(error=f"File '{file_path}': Line offset {offset} exceeds file length ({total_lines} lines)")

page = lines[offset : offset + limit]
content = "\n".join(page)
returned_lines = len(page)

# Cap rendered text at MAX_OUTPUT_BYTES and append TRUNCATION_MSG, so
# large pages don't reintroduce the transport-size symptom this
Expand All @@ -213,9 +215,27 @@ def read( # noqa: PLR0911 - early returns for distinct error conditions
msg_bytes = TRUNCATION_MSG.encode("utf-8")
effective_limit = MAX_OUTPUT_BYTES - len(msg_bytes)
if len(encoded) > effective_limit:
content = encoded[:effective_limit].decode("utf-8", errors="ignore") + TRUNCATION_MSG

return ReadResult(file_data=FileData(content=content, encoding="utf-8"))
truncated = encoded[:effective_limit].decode("utf-8", errors="ignore")
# The byte cap can drop whole lines from the page and cut the final
# rendered line mid-way. Advance the resume offset only past lines
# that were fully rendered (each is followed by its "\n"), so a
# re-read from `next_offset` never silently skips unshown lines; the
# partial boundary line is re-read from its start. Fall back to 1
# when even the first line overflows the cap, to guarantee forward
# progress instead of re-reading the same truncated page.
returned_lines = truncated.count("\n") or 1
content = truncated + TRUNCATION_MSG

end_line = offset + returned_lines
next_offset = end_line if end_line < total_lines else None

return ReadResult(
file_data=FileData(content=content, encoding="utf-8"),
total_lines=total_lines,
start_line=offset + 1,
end_line=end_line,
next_offset=next_offset,
)

def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
"""Download multiple files from the LangSmith sandbox.
Expand Down
59 changes: 53 additions & 6 deletions libs/deepagents/deepagents/backends/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,62 @@ class FileData(TypedDict):

@dataclass
class ReadResult:
"""Result from backend read operations.

Attributes:
error: Error message on failure, None on success.
file_data: FileData dict on success, None on failure.
"""
"""Result from backend read operations."""

error: str | None = None
"""Error message on failure, `None` on success."""

file_data: FileData | None = None
"""File data on success, `None` on failure."""

total_lines: int | None = None
"""Total number of source lines when the backend can determine it."""

start_line: int | None = None
"""1-indexed first source line returned in `file_data`."""

end_line: int | None = None
"""1-indexed last source line returned in `file_data`."""

next_offset: int | None = None
"""0-indexed offset for the next unread source line."""

def __post_init__(self) -> None:
"""Reject malformed pagination-field combinations at construction.

The window fields are not independent: `start_line`/`end_line` are a
pair, and neither `next_offset` nor `total_lines` describes anything
without the window it refers to. Beyond co-presence, the values must
agree numerically: a window runs forward (`1 <= start_line <=
end_line`), the file is at least as long as the window
(`total_lines >= end_line`), and the resume point is the 0-indexed line
immediately after the last one shown (`next_offset == end_line`, since
`end_line` is 1-indexed). Fail loudly here to keep a backend from
emitting a `next_offset` that would silently skip unshown source lines
once it reaches the middleware.
"""
if (self.start_line is None) != (self.end_line is None):
msg = "ReadResult.start_line and end_line must be set together or both left unset"
raise ValueError(msg)
if self.next_offset is not None and self.start_line is None:
msg = "ReadResult.next_offset requires start_line and end_line to be set"
raise ValueError(msg)
if self.total_lines is not None and self.start_line is None:
msg = "ReadResult.total_lines requires start_line and end_line to be set"
raise ValueError(msg)

# Numeric consistency of a present window. `start_line`/`end_line` are
# bound together above, so testing `start_line` covers both.
if self.start_line is not None and self.end_line is not None:
if self.start_line < 1 or self.end_line < self.start_line:
msg = f"ReadResult window must satisfy 1 <= start_line <= end_line, got start_line={self.start_line}, end_line={self.end_line}"
raise ValueError(msg)
if self.total_lines is not None and self.total_lines < self.end_line:
msg = f"ReadResult.total_lines ({self.total_lines}) cannot be less than end_line ({self.end_line})"
raise ValueError(msg)
if self.next_offset is not None and self.next_offset != self.end_line:
msg = f"ReadResult.next_offset ({self.next_offset}) must equal end_line ({self.end_line}), the 0-indexed line after the last shown"
raise ValueError(msg)


class _Unset:
Expand Down
113 changes: 93 additions & 20 deletions libs/deepagents/deepagents/backends/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,12 @@
# (glob filtering is irrelevant for a single-file search).
if os.path.isdir(search_path):
os.chdir(search_path)
# A leading `/` would make `glob.glob` treat the pattern as an
# A leading slash would make glob.glob treat the pattern as an
# absolute filesystem path, searching outside the search root (e.g.
# `/*.py` after `chdir('/workspace')` would match `/top.py` on
# the host, not `/workspace/top.py`). Strip it so anchored globs
# stay relative to the search root, matching the `FilesystemBackend`
# semantics where `/` anchors to the root, not the filesystem.
# /*.py after chdir('/workspace') would match /top.py on
# the host, not /workspace/top.py). Strip it so anchored globs
# stay relative to the search root, matching the FilesystemBackend
# semantics where slash anchors to the root, not the filesystem.
rel_glob = glob_pat.lstrip('/')
if any(seg == '..' for seg in rel_glob.replace(chr(92), '/').split('/')):
sys.stderr.write('glob contains path traversal\\n')
Expand All @@ -117,7 +117,7 @@
rel_files = sorted(glob.glob(rel_glob, recursive=True))
# Open the glob-relative path (cwd is the search root) but report the
# path prefixed with the search root, so GrepResult.path matches the
# `<root>/<match>` form that `grep -r` emits on the --include route.
# root/match form that grep -r emits on the --include route.
targets = []
for rel in rel_files:
real_open = os.path.realpath(rel)
Expand Down Expand Up @@ -355,7 +355,7 @@
leaves the sandbox), and cleans up the temp files.

Output: single-line JSON with `{{"count": N}}` on success or
`{{"error": ...}}` on failure. Same success contract as
`{{"error": ...}}` on failure. Same success contract as
`_EDIT_COMMAND_TEMPLATE`; additionally produces
`{{"error": "temp_read_failed", "detail": ...}}` when the uploaded temp
files cannot be read.
Expand All @@ -366,6 +366,7 @@

MAX_OUTPUT_BYTES = 500 * 1024
MAX_BINARY_BYTES = 500 * 1024
MAX_LINE_COUNT_BYTES = 1024 * 1024
TRUNCATION_MSG = '\\n\\n' + (
'[Output was truncated due to size limits. '
'This paginated read result exceeded the sandbox stdout limit. '
Expand Down Expand Up @@ -422,14 +423,21 @@
msg_bytes = len(TRUNCATION_MSG.encode('utf-8'))
effective_limit = MAX_OUTPUT_BYTES - msg_bytes

at_eof = False
with open(path, 'r', encoding='utf-8', newline=None) as f:
for raw_line in f:
line_count += 1
if line_count <= offset:
continue
if returned_lines >= limit:
while line_count < offset:
raw_line = f.readline()
if raw_line == '':
at_eof = True
break
line_count += 1

while not at_eof and returned_lines < limit and not truncated:
raw_line = f.readline()
if raw_line == '':
at_eof = True
break
line_count += 1
line = raw_line.rstrip('\\n').rstrip('\\r')
piece = line if returned_lines == 0 else '\\n' + line
piece_bytes = len(piece.encode('utf-8'))
Expand All @@ -447,15 +455,63 @@
current_bytes += piece_bytes
returned_lines += 1

# The page can fill (returned_lines == limit) exactly at EOF without the
# loop readline ever returning an empty string. Detect that via position:
# after reading whole lines from a UTF-8 handle the decoder state is clean
# at a line boundary, so tell() is the raw byte offset and equals st_size
# at EOF. Worst case if this ever misjudges is a surfaced offset-exceeds-
# length error on the next re-read (large files only, where total_lines
# stays None) -- never a silent skip, since a false at_eof of True cannot
# arise (a clean or packed tell() past EOF cannot equal st_size).
if not at_eof:
at_eof = f.tell() == st.st_size

if returned_lines == 0 and not truncated:
print(json.dumps({{'error': 'Line offset ' + str(offset) + ' exceeds file length (' + str(line_count) + ' lines)'}}))
sys.exit(0)

# When the page already reached EOF, reuse its scan's count for free.
# Otherwise re-scan for the total only when the file is small enough that
# the extra pass stays bounded; surrogateescape keeps an invalid byte after
# the requested page from invalidating content that was decoded successfully.
if at_eof:
total_lines = line_count
elif st.st_size <= MAX_LINE_COUNT_BYTES:
with open(path, 'r', encoding='utf-8', errors='surrogateescape', newline=None) as f:
total_lines = sum(1 for _ in f)
else:
total_lines = None

text = ''.join(parts)
if truncated:
text += TRUNCATION_MSG

print(json.dumps({{'encoding': 'utf-8', 'content': text}}))
# A byte cap can cut the final rendered line mid-way; that partial line is
# deliberately not counted toward returned_lines (see the truncation
# branch), so next_offset resumes at its start and the whole boundary line
# is re-read. If even the first requested line overflows the cap no full
# line was returned: advance by one so the read still makes progress instead
# of looping on the same page (that line's tail is unreadable via line
# offsets).
if truncated and returned_lines == 0:
returned_lines = 1

end_line = offset + returned_lines
if total_lines is not None:
next_offset = end_line if end_line < total_lines else None
else:
# total_lines is None only via the large-file branch above, which is
# reached only when the page stopped short of EOF, so lines always
# remain here.
next_offset = end_line
print(json.dumps({{
'encoding': 'utf-8',
'content': text,
'total_lines': total_lines,
'start_line': offset + 1,
'end_line': end_line,
'next_offset': next_offset,
}}))
except FileNotFoundError:
print(json.dumps({{'error': 'file_not_found'}}))
except PermissionError:
Expand All @@ -468,8 +524,15 @@
base64-encoded; `file_type`, `offset`, and `limit` are interpolated directly
(safe because they come from internal code, not user input).

Output: single-line JSON with either `{{"encoding": ..., "content": ...}}` on
success or `{{"error": ...}}` on failure.
Output: single-line JSON. On success (text): `{{"encoding", "content",
"total_lines", "start_line", "end_line", "next_offset"}}`, where `start_line`
and `end_line` are 1-indexed and `next_offset` is the 0-indexed offset of the
next unread line (`null` once the file is fully read). `total_lines` is `null`
when the file is large enough that a full re-scan to count its lines would be
unbounded. On success
(binary): `{{"encoding": "base64", "content": ...}}` without pagination keys.
An empty file short-circuits to `{{"encoding": "utf-8", "content": <empty-file
reminder>}}`, also without pagination keys. On failure: `{{"error": ...}}`.
"""


Expand Down Expand Up @@ -542,12 +605,22 @@ def _parse_read_output(output: str, file_path: str) -> ReadResult:
return ReadResult(error=f"File '{file_path}': unexpected server response: {detail}")
if "error" in data:
return ReadResult(error=f"File '{file_path}': {data['error']}")
return ReadResult(
file_data=FileData(
content=data["content"],
encoding=data.get("encoding", "utf-8"),
# A parseable-but-malformed payload (missing `content`, or a pagination-key
# combination `ReadResult.__post_init__` rejects) must degrade to the same
# clean error result as a decode failure, not escape as a raw traceback.
try:
return ReadResult(
file_data=FileData(
content=data["content"],
encoding=data.get("encoding", "utf-8"),
),
total_lines=data.get("total_lines"),
start_line=data.get("start_line"),
end_line=data.get("end_line"),
next_offset=data.get("next_offset"),
)
)
except (KeyError, TypeError, ValueError) as exc:
return ReadResult(error=f"File '{file_path}': unexpected server response: {exc}")
Comment thread
open-swe[bot] marked this conversation as resolved.


def _build_write_preflight_cmd(file_path: str) -> str:
Expand Down
13 changes: 1 addition & 12 deletions libs/deepagents/deepagents/backends/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,18 +234,7 @@ def read(
if _get_backend_read_file_type(file_path) != "text":
return ReadResult(file_data=file_data)

sliced = slice_read_response(file_data, offset, limit)
if isinstance(sliced, ReadResult):
return sliced
sliced_fd = FileData(
content=sliced,
encoding=file_data.get("encoding", "utf-8"),
)
if "created_at" in file_data:
sliced_fd["created_at"] = file_data["created_at"]
if "modified_at" in file_data:
sliced_fd["modified_at"] = file_data["modified_at"]
return ReadResult(file_data=sliced_fd)
return slice_read_response(file_data, offset, limit)

def write(
self,
Expand Down
Loading
Loading