diff --git a/libs/deepagents/deepagents/backends/langsmith.py b/libs/deepagents/deepagents/backends/langsmith.py index 92570ecfe3..db9ae58756 100644 --- a/libs/deepagents/deepagents/backends/langsmith.py +++ b/libs/deepagents/deepagents/backends/langsmith.py @@ -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. diff --git a/libs/deepagents/deepagents/backends/protocol.py b/libs/deepagents/deepagents/backends/protocol.py index ec8fb45374..7d248541b6 100644 --- a/libs/deepagents/deepagents/backends/protocol.py +++ b/libs/deepagents/deepagents/backends/protocol.py @@ -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. diff --git a/libs/deepagents/deepagents/backends/sandbox.py b/libs/deepagents/deepagents/backends/sandbox.py index b7d093d68c..b080a7c7f4 100644 --- a/libs/deepagents/deepagents/backends/sandbox.py +++ b/libs/deepagents/deepagents/backends/sandbox.py @@ -27,6 +27,7 @@ ASYNC_GREP_TIMEOUT, DeleteResult, EditResult, + ExecuteOffloadResult, ExecuteResponse, FileData, FileDownloadResponse, @@ -39,6 +40,7 @@ ReadResult, SandboxBackendProtocol, WriteResult, + execute_accepts_timeout, ) from deepagents.backends.utils import _get_file_type @@ -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: ` `.""" + +_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) ===== +# 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: + + \n + + 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") + 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. @@ -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, @@ -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)) diff --git a/libs/deepagents/deepagents/middleware/filesystem.py b/libs/deepagents/deepagents/middleware/filesystem.py index 5a1cbd1e5f..49be34916d 100644 --- a/libs/deepagents/deepagents/middleware/filesystem.py +++ b/libs/deepagents/deepagents/middleware/filesystem.py @@ -39,11 +39,13 @@ from deepagents._api.deprecation import warn_deprecated from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend, StateBackend +from deepagents.backends.composite import _route_for_path from deepagents.backends.protocol import ( BACKEND_TYPES as BACKEND_TYPES, # Re-export type here for backwards compatibility BackendProtocol, DeleteResult, EditResult, + ExecuteOffloadResult, FileData as FileData, # Re-export for backwards compatibility FileInfo, GlobResult, @@ -56,6 +58,7 @@ _supports_delete, execute_accepts_timeout, ) +from deepagents.backends.sandbox import BaseSandbox from deepagents.backends.utils import ( _get_file_type, _glob_anchor, @@ -1818,6 +1821,74 @@ async def async_grep( args_schema=GrepSchema, ) + def _resolve_capture(self, resolved_backend: BackendProtocol, tool_call_id: str | None) -> tuple[BaseSandbox, str] | None: + """Resolve the executing sandbox and offload path for capture-at-source. + + Capture-at-source writes output to a literal path via the sandbox shell + and later reads it back through the backend, which requires `execute()` + and `read_file` to resolve to the same filesystem at that path. Only + `BaseSandbox` provides that guarantee, so it is gated on it; the offload + path must also route to the executing backend rather than a different + composite route. + + Whether capture is actually applied is left to the executor's + `execute_with_offload` (which honors `enable_capture_offload`); this only + decides whether the offload path is valid to attempt. + + Returns: + `(executor, capture_path)` when capture-at-source can be attempted, or + `None` to skip it (eviction disabled, no tool-call id, the backend is + not a `BaseSandbox`, or the offload path routes elsewhere) — in which + case the caller uses plain execute plus generic eviction. + """ + if not self._tool_token_limit_before_evict or not tool_call_id: + return None + capture_path = f"{self._large_tool_results_prefix}/{sanitize_tool_call_id(tool_call_id)}" + if isinstance(resolved_backend, CompositeBackend): + default = resolved_backend.default + if not isinstance(default, BaseSandbox): + return None + backend, _backend_path, route_prefix = _route_for_path( + default=default, + sorted_routes=resolved_backend.sorted_routes, + path=capture_path, + ) + # Safe only when the path falls through to the default backend + # unchanged, since execute() also runs on the default. + if route_prefix is None and backend is default: + return default, capture_path + return None + if isinstance(resolved_backend, BaseSandbox): + return resolved_backend, capture_path + return None + + @staticmethod + def _format_execute_output(output: str, exit_code: int | None, *, truncated: bool) -> str: + """Format raw command output with status and truncation notes for the model.""" + parts = [output] + if exit_code is not None: + cmd_status = "succeeded" if exit_code == 0 else "failed" + parts.append(f"\n[Command {cmd_status} with exit code {exit_code}]") + if truncated: + parts.append("\n[Output was truncated due to size limits]") + return "".join(parts) + + def _interpret_capture_output(self, offload: ExecuteOffloadResult, capture_path: str, tool_call_id: str) -> str: + """Build `ToolMessage` content from an `execute_with_offload` result.""" + response = offload.response + if not offload.offloaded: + return self._format_execute_output(response.output, response.exit_code, truncated=response.truncated) + cmd_status = "succeeded" if response.exit_code == 0 else "failed" + status_line = f"[Command {cmd_status} with exit code {response.exit_code}]" + if response.truncated: + status_line += "\n[Output exceeded the capture size limit and was truncated; the saved file is incomplete]" + content_sample = f"{status_line}\n{response.output}" + return TOO_LARGE_TOOL_MSG.format( + tool_call_id=tool_call_id, + file_path=capture_path, + content_sample=content_sample, + ) + def _create_execute_tool(self) -> BaseTool: # noqa: C901 """Create the execute tool for sandbox command execution.""" tool_description = self._custom_tool_descriptions.get("execute") or EXECUTE_TOOL_DESCRIPTION @@ -1876,8 +1947,20 @@ def sync_execute( # noqa: PLR0911 - early returns for distinct error conditions tool_call_id=runtime.tool_call_id, status="error", ) + capture = self._resolve_capture(resolved_backend, runtime.tool_call_id) try: - result = executable.execute(command, timeout=timeout) if timeout is not None else executable.execute(command) + if capture is not None: + executor, capture_path = capture + offload = executor.execute_with_offload( + command, + capture_path, + max_inline_bytes=NUM_CHARS_PER_TOKEN * cast("int", self._tool_token_limit_before_evict), + timeout=timeout, + ) + content = self._interpret_capture_output(offload, capture_path, cast("str", runtime.tool_call_id)) + else: + result = executable.execute(command, timeout=timeout) if timeout is not None else executable.execute(command) + content = self._format_execute_output(result.output, result.exit_code, truncated=result.truncated) except NotImplementedError as e: return ToolMessage( content=f"Error: Execution not available. {e}", @@ -1893,18 +1976,8 @@ def sync_execute( # noqa: PLR0911 - early returns for distinct error conditions status="error", ) - # Format output for LLM consumption - parts = [result.output] - - if result.exit_code is not None: - cmd_status = "succeeded" if result.exit_code == 0 else "failed" - parts.append(f"\n[Command {cmd_status} with exit code {result.exit_code}]") - - if result.truncated: - parts.append("\n[Output was truncated due to size limits]") - return ToolMessage( - content="".join(parts), + content=content, name="execute", tool_call_id=runtime.tool_call_id, status="success", @@ -1965,8 +2038,20 @@ async def async_execute( # noqa: PLR0911 - early returns for distinct error con tool_call_id=runtime.tool_call_id, status="error", ) + capture = self._resolve_capture(resolved_backend, runtime.tool_call_id) try: - result = await executable.aexecute(command, timeout=timeout) if timeout is not None else await executable.aexecute(command) + if capture is not None: + executor, capture_path = capture + offload = await executor.aexecute_with_offload( + command, + capture_path, + max_inline_bytes=NUM_CHARS_PER_TOKEN * cast("int", self._tool_token_limit_before_evict), + timeout=timeout, + ) + content = self._interpret_capture_output(offload, capture_path, cast("str", runtime.tool_call_id)) + else: + result = await executable.aexecute(command, timeout=timeout) if timeout is not None else await executable.aexecute(command) + content = self._format_execute_output(result.output, result.exit_code, truncated=result.truncated) except NotImplementedError as e: return ToolMessage( content=f"Error: Execution not available. {e}", @@ -1982,18 +2067,8 @@ async def async_execute( # noqa: PLR0911 - early returns for distinct error con status="error", ) - # Format output for LLM consumption - parts = [result.output] - - if result.exit_code is not None: - cmd_status = "succeeded" if result.exit_code == 0 else "failed" - parts.append(f"\n[Command {cmd_status} with exit code {result.exit_code}]") - - if result.truncated: - parts.append("\n[Output was truncated due to size limits]") - return ToolMessage( - content="".join(parts), + content=content, name="execute", tool_call_id=runtime.tool_call_id, status="success", diff --git a/libs/deepagents/tests/unit_tests/test_local_sandbox_operations.py b/libs/deepagents/tests/unit_tests/test_local_sandbox_operations.py index 125cc63683..3379efe4a6 100644 --- a/libs/deepagents/tests/unit_tests/test_local_sandbox_operations.py +++ b/libs/deepagents/tests/unit_tests/test_local_sandbox_operations.py @@ -26,7 +26,9 @@ from pathlib import Path import pytest +from langchain.tools import ToolRuntime +from deepagents.backends import CompositeBackend from deepagents.backends.filesystem import _map_exception_to_standard_error from deepagents.backends.protocol import ( EditResult, @@ -40,6 +42,7 @@ WriteResult, ) from deepagents.backends.sandbox import _EDIT_INLINE_MAX_BYTES, BaseSandbox +from deepagents.middleware.filesystem import FilesystemMiddleware # Skip all tests in this module unless RUN_SANDBOX_TESTS=true pytestmark = pytest.mark.skipif( @@ -58,6 +61,8 @@ def __init__(self) -> None: self._id = "local-subprocess-sandbox" self._virtual_root = VIRTUAL_SANDBOX_ROOT self._real_root = self._virtual_root + # Real host shell, so capture-at-source (opt-in, default off) is supported. + self.enable_capture_offload = True def set_real_root(self, real_root: str) -> None: """Set the on-disk directory used for test file operations.""" @@ -1599,3 +1604,126 @@ def test_complex_directory_operations(self, sandbox: LocalSubprocessSandbox) -> grep_result = sandbox.grep("file", path=base_dir).matches assert grep_result is not None assert len(grep_result) >= 3 # At least 3 matches + + +# 5000 of these lines (~250 KB) clear the default eviction budget (~80 KB). +_BIG_OUTPUT_CMD = 'for i in $(seq 1 5000); do echo "line $i: padding text to make the output long enough to offload"; done' + + +class TestExecuteCaptureOffload: + """End-to-end capture-at-source offload via the execute tool on a real shell. + + Drives the `execute` and `read_file` tools through a `CompositeBackend` whose + default is a `LocalSubprocessSandbox` and whose `artifacts_root` lives under + the translated virtual root, so the wrapper's capture file lands in the test + directory rather than the host filesystem root. + """ + + @pytest.fixture(scope="class") + def sandbox(self) -> Iterator[LocalSubprocessSandbox]: + return LocalSubprocessSandbox() + + @pytest.fixture(autouse=True) + def setup_test_dir(self, sandbox: LocalSubprocessSandbox, tmp_path: Path) -> None: + sandbox.set_real_root(str(tmp_path / "sandbox_ops")) + sandbox.execute("rm -rf /tmp/test_sandbox_ops && mkdir -p /tmp/test_sandbox_ops") + + @pytest.fixture + def tools(self, sandbox: LocalSubprocessSandbox) -> tuple: + backend = CompositeBackend(default=sandbox, routes={}, artifacts_root=VIRTUAL_SANDBOX_ROOT) + middleware = FilesystemMiddleware(backend=backend) + execute_tool = next(t for t in middleware.tools if t.name == "execute") + read_tool = next(t for t in middleware.tools if t.name == "read_file") + return execute_tool, read_tool + + @staticmethod + def _runtime(tool_call_id: str) -> ToolRuntime: + return ToolRuntime( + state={}, + context=None, + tool_call_id=tool_call_id, + store=None, + stream_writer=lambda _: None, + config={}, + ) + + @staticmethod + def _capture_path(tool_call_id: str) -> str: + return f"{VIRTUAL_SANDBOX_ROOT}/large_tool_results/{tool_call_id}" + + def test_small_output_returned_inline_and_leaves_no_file(self, tools: tuple, sandbox: LocalSubprocessSandbox) -> None: + execute_tool, _ = tools + result = execute_tool.invoke({"command": "echo hello", "runtime": self._runtime("c_small")}) + + assert "hello" in result.content + assert "exit code 0" in result.content + # Small results are not offloaded -- no pointer, and the capture file is removed. + assert "large_tool_results" not in result.content + listing = sandbox.execute(f"ls {VIRTUAL_SANDBOX_ROOT}/large_tool_results/ 2>/dev/null | wc -l") + assert listing.output.strip() == "0" + + def test_large_output_offloads_and_full_content_roundtrips(self, tools: tuple) -> None: + execute_tool, read_tool = tools + rt = self._runtime("c_large") + result = execute_tool.invoke({"command": _BIG_OUTPUT_CMD, "runtime": rt}) + + capture_path = self._capture_path("c_large") + # Preview + pointer, not the full output inline. + assert capture_path in result.content + assert "read_file" in result.content + assert "line 1:" in result.content # head shown + assert "line 5000:" in result.content # tail shown + assert "lines truncated" in result.content + # A middle line is absent from the preview... + assert "line 2500:" not in result.content + + # ...but recoverable in full via read_file on the offload path: a middle + # slice the preview never showed is present on disk. + read = read_tool.invoke({"file_path": capture_path, "offset": 2499, "limit": 3, "runtime": rt}) + assert "line 2500:" in read.content + + def test_nonzero_exit_code_preserved(self, tools: tuple) -> None: + execute_tool, _ = tools + result = execute_tool.invoke({"command": "echo oops; exit 3", "runtime": self._runtime("c_ec")}) + + assert "oops" in result.content + assert "exit code 3" in result.content + + def test_runaway_output_is_capped_and_flagged(self, tools: tuple, sandbox: LocalSubprocessSandbox, monkeypatch: pytest.MonkeyPatch) -> None: + # Cap must exceed the eviction budget so a capped result still offloads + # (rather than fitting inline). Default budget is ~80 KB. + cap = 100_000 + monkeypatch.setattr("deepagents.backends.sandbox._EXECUTE_CAPTURE_MAX_BYTES", cap) + + execute_tool, _ = tools + rt = self._runtime("c_cap") + # ~250 KB of output over the cap, but the command exits 0. The cap drains + # the excess instead of SIGPIPE-killing the producer, so the command's real + # exit code survives -- a regression guard: closing the pipe early would + # report this successful command as failed. + result = execute_tool.invoke({"command": f"{_BIG_OUTPUT_CMD}; exit 0", "runtime": rt}) + + assert "exceeded the capture size limit" in result.content + assert "succeeded with exit code 0" in result.content + # The on-disk capture file is bounded at the cap regardless of total output. + size = sandbox.execute(f"wc -c < {self._capture_path('c_cap')}").output.strip() + assert size == str(cap) + + def test_enable_capture_offload_flag_controls_offload(self, sandbox: LocalSubprocessSandbox) -> None: + budget = 100 # small, so _BIG_OUTPUT_CMD would offload when capture is enabled + + # Disabled -> command runs unwrapped: full output inline, not offloaded, no file. + sandbox.enable_capture_offload = False + off_path = self._capture_path("flag_off") + offload = sandbox.execute_with_offload(_BIG_OUTPUT_CMD, off_path, max_inline_bytes=budget) + assert offload.offloaded is False + assert "line 5000:" in offload.response.output # full output returned, not a preview + assert "line 2500:" in offload.response.output + assert sandbox.execute(f"test -e {off_path} && echo Y || echo N").output.strip() == "N" + + # Enabled -> offloaded to a file; only a head/tail preview is returned. + sandbox.enable_capture_offload = True + on_path = self._capture_path("flag_on") + offload = sandbox.execute_with_offload(_BIG_OUTPUT_CMD, on_path, max_inline_bytes=budget) + assert offload.offloaded is True + assert "line 2500:" not in offload.response.output # middle omitted -> it's a preview