Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions libs/deepagents/deepagents/backends/langsmith.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def _binary_read_result(file_path: str, raw: bytes) -> ReadResult:
class LangSmithSandbox(BaseSandbox):
"""LangSmith sandbox implementation conforming to [`SandboxBackendProtocol`][deepagents.backends.protocol.SandboxBackendProtocol]."""

# LangSmith sandbox images ship a POSIX shell + coreutils compatible with the
# capture wrapper, so opt in to capture-at-source offload for `execute`.
enable_capture_offload = True

def __init__(self, sandbox: Sandbox) -> None:
"""Create a backend wrapping an existing LangSmith sandbox.

Expand Down
20 changes: 20 additions & 0 deletions libs/deepagents/deepagents/backends/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,26 @@ class ExecuteResponse:
"""Whether the output was truncated due to backend limitations."""


@dataclass(frozen=True, slots=True)
class ExecuteOffloadResult:
"""Result of [`BaseSandbox.execute_with_offload`][deepagents.backends.sandbox.BaseSandbox.execute_with_offload].

`offloaded` describes the capture mechanism and is kept off `ExecuteResponse`
(which an ordinary `execute` never sets).
"""

offloaded: bool
"""Whether the output was left at the capture path.

When `True`, `response.output` holds only a head/tail preview and the full
output lives at the capture path on the sandbox filesystem. When `False`,
`response.output` is the complete output.
"""

response: ExecuteResponse
"""The command result. `response.truncated` indicates the output hit the size cap."""


class SandboxBackendProtocol(BackendProtocol):
"""Extension of `BackendProtocol` that adds shell command execution.

Expand Down
207 changes: 207 additions & 0 deletions libs/deepagents/deepagents/backends/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
ASYNC_GREP_TIMEOUT,
DeleteResult,
EditResult,
ExecuteOffloadResult,
ExecuteResponse,
FileData,
FileDownloadResponse,
Expand All @@ -39,6 +40,7 @@
ReadResult,
SandboxBackendProtocol,
WriteResult,
execute_accepts_timeout,
)
from deepagents.backends.utils import _get_file_type

Expand Down Expand Up @@ -580,6 +582,148 @@ def _build_edit_tmpfile_cmd(file_path: str, old_tmp: str, new_tmp: str, *, repla
)


_EXECUTE_CAPTURE_SENTINEL: Final = "__DEEPAGENTS_EXEC_META__"
"""First-line marker identifying capture-wrapper output: `<sentinel> <exit_code> <offloaded> <capped>`."""

_EXECUTE_CAPTURE_HEAD_LINES: Final = 5
_EXECUTE_CAPTURE_TAIL_LINES: Final = 5
_EXECUTE_CAPTURE_HEAD_BYTES: Final = 2000
_EXECUTE_CAPTURE_TAIL_BYTES: Final = 2000

_EXECUTE_CAPTURE_MAX_BYTES: Final = 10 * 1024 * 1024
"""Hard cap on captured stdout/stderr persisted to the sandbox.

Bounds sandbox disk use for runaway output: the captured stream is piped through
`head -c`, so when the cap is hit the writer receives `SIGPIPE` and nothing
further reaches disk even if the command ignores the signal. Set well above the
inline budget so legitimately large output is still preserved in full; output
beyond the cap is truncated and flagged.
"""

# The captured stream is piped into `head -c` (caps the on-disk file) followed by
# `cat > /dev/null` (drains the rest), so the file can never exceed the cap yet the
# command still reaches EOF and exits normally -- closing the pipe early would
# SIGPIPE-kill it and corrupt its exit code. Because the command is in a pipeline,
# its real exit code is recovered from a sidecar file rather than `$?` (which would
# be the pipeline's). The command runs in a subshell so a command `exit` cannot
# abort the wrapper, and `eval` preserves the backend's own shell/env. The command
# is embedded via a quoted heredoc with a random delimiter to avoid shell-quoting
# issues; the (internal, sanitized) path is shell-quoted.
_EXECUTE_CAPTURE_CMD_TEMPLATE = """# ===== deepagents capture-at-source offload (auto-generated wrapper) =====

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breakdown of what's happening here:

  • Redirect the command's combined stdout/stderr to a file in the sandbox (> "$__da_f" 2>&1) instead of streaming it back, so the full payload never leaves the sandbox.
  • Embed the requested command verbatim via a quoted heredoc (cat <<'DELIM' … DELIM) and run it in a subshell via eval (( eval "$__da_cmd" )). The heredoc avoids any shell-quoting pitfalls; the subshell+eval runs the command in the sandbox's own shell/env and ensures a stray exit in the user's command can't abort the wrapper.
  • Pipe the captured stream through head -c (default 10 MiB) so the on-disk file can never exceed a hard cap — this bounds sandbox disk usage and restores backpressure (the writer gets SIGPIPE) for runaway output like yes.
  • Recover the command's real exit code from a sidecar file (echo "$?" > "$__da_f.ec"). The capture pipe would otherwise leave $? as head's exit code, not the command's.
  • Measure the captured size (wc -c) and branch:
    • Under the eviction threshold → print the full output inline and delete the file. Indistinguishable from today's small-result behavior.
    • Over the threshold → leave the file at <artifacts_root>/large_tool_results/<tool_call_id> and print only a head/tail preview (whole lines, byte-bounded) with a [N lines truncated] marker.
  • Emit a one-line machine-readable header — <exit_code> — that the agent server parses to rebuild the ExecuteResponse and decide between returning the output inline vs. a preview + a read_file pointer.

# Runs the requested command below, capturing its combined output to a file in
# the sandbox: returned inline when small, or as a head/tail preview when large
# (the full result stays at the path for read_file). Disable this wrapping with
# BaseSandbox.enable_capture_offload = False.
__da_f=__PATH_Q__
__da_ecf="$__da_f.ec"
mkdir -p "$(dirname "$__da_f")" 2>/dev/null
# ----- requested command (verbatim, between the heredoc markers) -----
__da_cmd=$(cat <<'__DELIM__'
__COMMAND__
__DELIM__
)
# ----- end requested command; everything below is offload machinery -----
{ ( eval "$__da_cmd" ); echo "$?" > "$__da_ecf"; } 2>&1 | { head -c __MAXBYTES__ > "$__da_f"; cat > /dev/null; }
__da_ec=$(cat "$__da_ecf" 2>/dev/null)
: "${__da_ec:=1}"
rm -f "$__da_ecf"
__da_bytes=$(wc -c < "$__da_f" 2>/dev/null | tr -d ' ')
: "${__da_bytes:=0}"
__da_capped=0
[ "$__da_bytes" -ge __MAXBYTES__ ] && __da_capped=1
if [ "$__da_bytes" -le __BUDGET__ ]; then
printf '%s %s %s %s\\n' '__SENTINEL__' "$__da_ec" 0 0
cat "$__da_f"
rm -f "$__da_f"
else
__da_lines=$(wc -l < "$__da_f" 2>/dev/null | tr -d ' ')
: "${__da_lines:=0}"
__da_omitted=$((__da_lines - __HEADLINES__ - __TAILLINES__))
printf '%s %s %s %s\\n' '__SENTINEL__' "$__da_ec" 1 "$__da_capped"
if [ "$__da_omitted" -gt 0 ]; then
head -c __HEAD__ "$__da_f" | head -n __HEADLINES__
printf '... [%s lines truncated] ...\\n' "$__da_omitted"
tail -c __TAIL__ "$__da_f" | tail -n __TAILLINES__
else
head -c $((__HEAD__ + __TAIL__)) "$__da_f"
fi
fi
"""
# Pure POSIX sh wrapper for capture-at-source `execute`; see the comment above the template.


def _new_heredoc_delim() -> str:
"""Return a random heredoc delimiter, e.g. `__DEEPAGENTS_CMD_<80 random bits>__`."""
return "__DEEPAGENTS_CMD_" + base64.b32encode(os.urandom(10)).decode("ascii").rstrip("=") + "__"


def _build_capture_execute_cmd(command: str, capture_path: str, *, inline_budget: int, max_capture_bytes: int | None = None) -> str:
"""Build the capture-at-source wrapper command for `execute`.

`inline_budget` is the byte threshold at or below which output is returned
inline; above it the output is left at `capture_path` and only a head/tail
preview is returned. Captured output is hard-capped at `max_capture_bytes`
(defaulting to `_EXECUTE_CAPTURE_MAX_BYTES`, resolved here so it stays
overridable/patchable); beyond that it is truncated and flagged.
"""
cap = max_capture_bytes if max_capture_bytes is not None else _EXECUTE_CAPTURE_MAX_BYTES
# The command is embedded in a quoted heredoc; guarantee the delimiter cannot
# appear in it so the command can never terminate the heredoc early. The
# delimiter is 80 random bits, so this regenerates only astronomically rarely.
delim = _new_heredoc_delim()
while delim in command:
delim = _new_heredoc_delim()
# __COMMAND__ is substituted last so command content can never collide with a
# remaining placeholder token.
return (
_EXECUTE_CAPTURE_CMD_TEMPLATE.replace("__PATH_Q__", shlex.quote(capture_path))
.replace("__DELIM__", delim)
.replace("__MAXBYTES__", str(cap))
.replace("__BUDGET__", str(inline_budget))
.replace("__SENTINEL__", _EXECUTE_CAPTURE_SENTINEL)
.replace("__HEADLINES__", str(_EXECUTE_CAPTURE_HEAD_LINES))
.replace("__TAILLINES__", str(_EXECUTE_CAPTURE_TAIL_LINES))
.replace("__HEAD__", str(_EXECUTE_CAPTURE_HEAD_BYTES))
.replace("__TAIL__", str(_EXECUTE_CAPTURE_TAIL_BYTES))
.replace("__COMMAND__", command)
)


def _parse_capture_execute_output(output: str, *, backend_truncated: bool = False) -> ExecuteOffloadResult:
r"""Parse capture-wrapper stdout into an `ExecuteOffloadResult`.

The wrapper emits a meta line followed by the body:

<sentinel> <exit_code> <offloaded> <capped>\n<inline output or preview>

i.e. four space-separated fields on the first line — the sentinel, the
command's exit code, `1`/`0` for whether output was offloaded to the capture
file, and `1`/`0` for whether it hit the size cap — then everything after the
first newline is the body (full output when inline, head/tail preview when
offloaded).

Falls back to `offloaded=False` with the raw output when the meta line is
absent or malformed — e.g. if the backend truncated transport; the caller
must not re-run the command in that case. `response.truncated` is set when the
captured output hit the size cap (the saved file is incomplete) or
`backend_truncated` is passed through from the underlying `execute`.
"""
first, _, body = output.partition("\n")
Comment thread
ccurme marked this conversation as resolved.
parts = first.split(" ")
# Expect exactly the four meta fields described above; anything else is not
# our wrapper's output, so fall back to returning it verbatim.
if len(parts) != 4 or parts[0] != _EXECUTE_CAPTURE_SENTINEL: # noqa: PLR2004
return ExecuteOffloadResult(offloaded=False, response=ExecuteResponse(output=output, truncated=backend_truncated))
try:
exit_code = int(parts[1])
except ValueError:
return ExecuteOffloadResult(offloaded=False, response=ExecuteResponse(output=output, truncated=backend_truncated))
return ExecuteOffloadResult(
offloaded=parts[2] == "1",
response=ExecuteResponse(output=body, exit_code=exit_code, truncated=parts[3] == "1" or backend_truncated),
)


class BaseSandbox(SandboxBackendProtocol, ABC):
"""Base sandbox implementation with `execute()` as the core abstract method.

Expand All @@ -602,6 +746,18 @@ class BaseSandbox(SandboxBackendProtocol, ABC):
and the `id` property.
"""

enable_capture_offload: bool = False
"""Whether `FilesystemMiddleware` may use capture-at-source offload for `execute`.

When `True`, large `execute` output is captured to a file in the sandbox and
only a preview is returned, avoiding a round-trip back through the agent
process. Defaults to `False` (opt-in) because the capture wrapper's shell and
coreutils assumptions are not guaranteed on every sandbox image; subclasses
known to be compatible set it to `True`. When `False`, `execute_with_offload`
runs the command unwrapped and the middleware falls back to inline execution
plus generic eviction.
"""

@abstractmethod
def execute(
self,
Expand All @@ -621,6 +777,57 @@ def execute(
`ExecuteResponse` with combined output, exit code, and truncation flag.
"""

def execute_with_offload(
self,
command: str,
capture_path: str,
*,
max_inline_bytes: int,
max_capture_bytes: int | None = None,
timeout: int | None = None,
) -> ExecuteOffloadResult:
"""Run `command`, offloading large output to a file in the sandbox.

Captures the command's combined output: returned inline when it is at or
below `max_inline_bytes`, otherwise left at `capture_path` (so the caller
can surface a `read_file` pointer) with only a head/tail preview returned.
Captured output is hard-capped at `max_capture_bytes` (default
`_EXECUTE_CAPTURE_MAX_BYTES`) without killing the command, so the exit
code is preserved. When `enable_capture_offload` is `False`, the command
runs unwrapped and the full output is returned (`offloaded=False`), so
callers can fall back to their own handling (e.g. generic eviction).

Returns:
An `ExecuteOffloadResult`. `offloaded=True` when the result was left
at `capture_path` and `response.output` holds only the preview;
`offloaded=False` when `response.output` is the complete output.
"""
use_timeout = timeout is not None and execute_accepts_timeout(type(self))
if not self.enable_capture_offload:
result = self.execute(command, timeout=timeout) if use_timeout else self.execute(command)
return ExecuteOffloadResult(offloaded=False, response=result)
wrapper = _build_capture_execute_cmd(command, capture_path, inline_budget=max_inline_bytes, max_capture_bytes=max_capture_bytes)
result = self.execute(wrapper, timeout=timeout) if use_timeout else self.execute(wrapper)
return _parse_capture_execute_output(result.output, backend_truncated=result.truncated)

async def aexecute_with_offload(
self,
command: str,
capture_path: str,
*,
max_inline_bytes: int,
max_capture_bytes: int | None = None,
timeout: int | None = None, # noqa: ASYNC109 # forwarded to the backend, not an asyncio timeout
) -> ExecuteOffloadResult:
"""Async version of `execute_with_offload`, delegating to `aexecute`."""
use_timeout = timeout is not None and execute_accepts_timeout(type(self))
if not self.enable_capture_offload:
result = await self.aexecute(command, timeout=timeout) if use_timeout else await self.aexecute(command)
return ExecuteOffloadResult(offloaded=False, response=result)
wrapper = _build_capture_execute_cmd(command, capture_path, inline_budget=max_inline_bytes, max_capture_bytes=max_capture_bytes)
result = await self.aexecute(wrapper, timeout=timeout) if use_timeout else await self.aexecute(wrapper)
return _parse_capture_execute_output(result.output, backend_truncated=result.truncated)

def ls(self, path: str) -> LsResult:
"""Structured listing with file metadata using os.scandir."""
result = self.execute(_build_ls_cmd(path))
Expand Down
Loading