diff --git a/crates/bashkit-python/README.md b/crates/bashkit-python/README.md index 5db25e4bf..6c83f5958 100644 --- a/crates/bashkit-python/README.md +++ b/crates/bashkit-python/README.md @@ -696,8 +696,16 @@ binary files are read as base64 (up to 500 KB) so multimodal models can see them, and upload/download failures use the protocol's error codes (`file_not_found`, `is_directory`, `permission_denied`, `invalid_path`). +Large command output is kept in the virtual filesystem: when Deep Agents +offloads an `execute` result, the full combined output (up to 10 MB, beyond the +1 MB in-memory stdout cap) is written to `/large_tool_results/` and the +model gets a head/tail preview it can follow up with `grep` or `read_file`. +Shell state (`cd`, `export`, functions) carries over between commands. + `Bash`/`BashTool` `execute*` methods accept the same per-call cap directly: -`execute_sync("make test", timeout_seconds=10)`. +`execute_sync("make test", timeout_seconds=10)`. `execute`/`execute_sync` can +also tee combined output to a VFS file: +`execute_sync("pytest", output_file="/tmp/pytest.log", max_output_file_bytes=10_000_000)`. ## API Reference diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index e412f2b64..c3f5408ac 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -615,6 +615,8 @@ class Bash: stdout: ByteSink | None = None, stderr: ByteSink | None = None, timeout_seconds: float | None = None, + output_file: str | None = None, + max_output_file_bytes: int | None = None, ) -> ExecResult: """Execute bash commands asynchronously. @@ -624,6 +626,10 @@ class Bash: pairs during execution. Must be synchronous. timeout_seconds: Optional wall-clock cap for this call only. It can only tighten the constructor ``timeout_seconds``, never extend it. + output_file: Optional absolute VFS path that receives combined + stdout+stderr, created or truncated first. Bounded by + ``max_output_file_bytes`` (default 10 MiB) and filesystem limits, + not the in-memory output caps. Not combinable with ``on_output``. Async ``custom_builtins`` callbacks run on the caller's active asyncio loop. @@ -642,7 +648,13 @@ class Bash: ... def execute_sync( - self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None + self, + commands: str, + on_output: OutputHandler | None = None, + *, + timeout_seconds: float | None = None, + output_file: str | None = None, + max_output_file_bytes: int | None = None, ) -> ExecResult: """Execute bash commands synchronously (blocking). @@ -1261,6 +1273,8 @@ class BashTool: stdout: ByteSink | None = None, stderr: ByteSink | None = None, timeout_seconds: float | None = None, + output_file: str | None = None, + max_output_file_bytes: int | None = None, ) -> ExecResult: """Execute bash commands asynchronously. @@ -1279,7 +1293,13 @@ class BashTool: ... def execute_sync( - self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None + self, + commands: str, + on_output: OutputHandler | None = None, + *, + timeout_seconds: float | None = None, + output_file: str | None = None, + max_output_file_bytes: int | None = None, ) -> ExecResult: """Execute bash commands synchronously (blocking). diff --git a/crates/bashkit-python/bashkit/deepagents.py b/crates/bashkit-python/bashkit/deepagents.py index 666557939..213a80bea 100644 --- a/crates/bashkit-python/bashkit/deepagents.py +++ b/crates/bashkit-python/bashkit/deepagents.py @@ -62,6 +62,50 @@ _MAX_BINARY_READ_BYTES = 500 * 1024 +# Capture-at-source preview shape, mirroring Deep Agents' `BaseSandbox` wrapper +# so the model sees the same head/tail excerpt whichever sandbox it runs in. +_CAPTURE_MAX_BYTES = 10 * 1024 * 1024 +_CAPTURE_HEAD_LINES = 5 +_CAPTURE_TAIL_LINES = 5 +_CAPTURE_HEAD_BYTES = 2000 +_CAPTURE_TAIL_BYTES = 2000 +_CAPTURE_CLIP_NOTICE = "... [output clipped here; {captured_bytes} bytes total, full output at the path above] ..." +_CAPTURE_CLIP_NOTICE_CAPPED = ( + "... [output clipped here; capture stopped at its {captured_bytes}-byte limit, " + "so the file at the path above is also incomplete] ..." +) +_CAPTURE_EXCERPT_CLIP_NOTICE = ( + "... [the excerpts above and below are byte-capped, so the lines bordering this marker may be cut mid-line] ..." +) + + +def _capture_preview(data: bytes, *, capped: bool) -> tuple[str, bool]: + """Build the offload preview for captured output. + + Returns ``(preview, has_truncation_marker)``: a head/tail excerpt around a + truncation marker when there are more lines than the head+tail budget, + otherwise a leading byte excerpt closed by a clip notice. + """ + omitted = data.count(b"\n") - _CAPTURE_HEAD_LINES - _CAPTURE_TAIL_LINES + if omitted > 0: + head_lines = data.splitlines(keepends=True)[:_CAPTURE_HEAD_LINES] + tail_lines = data.splitlines(keepends=True)[-_CAPTURE_TAIL_LINES:] + head = b"".join(data[:_CAPTURE_HEAD_BYTES].splitlines(keepends=True)[:_CAPTURE_HEAD_LINES]) + tail = b"".join(data[-_CAPTURE_TAIL_BYTES:].splitlines(keepends=True)[-_CAPTURE_TAIL_LINES:]) + parts = [head] + if sum(map(len, head_lines)) > _CAPTURE_HEAD_BYTES or sum(map(len, tail_lines)) > _CAPTURE_TAIL_BYTES: + parts.append(b"\n" + _CAPTURE_EXCERPT_CLIP_NOTICE.encode() + b"\n") + parts.append(TRUNCATION_MARKER_TEMPLATE.format(omitted_lines=omitted).encode() + b"\n") + parts.append(tail) + return b"".join(parts).decode("utf-8", errors="replace"), True + budget = _CAPTURE_HEAD_BYTES + _CAPTURE_TAIL_BYTES + preview = data[:budget] + if len(data) > budget: + notice = _CAPTURE_CLIP_NOTICE_CAPPED if capped else _CAPTURE_CLIP_NOTICE + preview += b"\n" + notice.format(captured_bytes=len(data)).encode() + b"\n" + return preview.decode("utf-8", errors="replace"), False + + def _file_error_code(exc: Exception) -> str: """Map a native VFS error to deepagents' `FileOperationError` codes. @@ -113,6 +157,7 @@ def _lines(content: str): from deepagents.backends.protocol import ( DeleteResult, EditResult, + ExecuteOffloadResult, ExecuteResponse, FileDownloadResponse, FileInfo, @@ -125,7 +170,12 @@ def _lines(content: str): SandboxBackendProtocol, WriteResult, ) - from deepagents.backends.utils import _get_backend_read_file_type, compile_grep_include_glob + from deepagents.backends.sandbox import BaseSandbox + from deepagents.backends.utils import ( + TRUNCATION_MARKER_TEMPLATE, + _get_backend_read_file_type, + compile_grep_include_glob, + ) from langchain.agents.middleware.types import AgentMiddleware from langchain_core.tools import StructuredTool @@ -134,6 +184,7 @@ def _lines(content: str): DEEPAGENTS_AVAILABLE = False if not TYPE_CHECKING: SandboxBackendProtocol = object + BaseSandbox = object AgentMiddleware = object @@ -260,7 +311,14 @@ def reset(self) -> None: if self._owns_bash: self._bash.reset() - class BashkitBackend(SandboxBackendProtocol): + # Decision: subclass `BaseSandbox` only so Deep Agents routes large + # `execute` output through `execute_with_offload` (it gates that on + # `isinstance(..., BaseSandbox)`). Every inherited shell/python3-script + # helper is overridden with a direct VFS implementation, and capture uses + # the native `output_file` tee: no wrapper script, `eval` or subshell, so + # `cd`/`export` still carry over and output beyond the in-memory stdout cap + # survives in the file. + class BashkitBackend(BaseSandbox): """Backend implementing SandboxBackendProtocol with Bashkit VFS. Provides execute, read, write, edit, delete, ls, glob, grep @@ -346,6 +404,77 @@ async def aexecute(self, command: str, *, timeout: int | None = None) -> Execute result = await self._bash.execute(command, timeout_seconds=self._timeout_seconds(timeout)) return self._execute_response(result) + enable_capture_offload = True + + 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`, keeping its combined output in a VFS file when large.""" + if not self.enable_capture_offload: + return ExecuteOffloadResult(offloaded=False, response=self.execute(command, timeout=timeout)) + cap = self._prepare_capture(capture_path, max_capture_bytes) + result = self._bash.execute_sync( + command, + timeout_seconds=self._timeout_seconds(timeout), + output_file=capture_path, + max_output_file_bytes=cap, + ) + return self._offload_result(result, capture_path, cap, max_inline_bytes) + + 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 + ) -> ExecuteOffloadResult: + """Async version of `execute_with_offload`.""" + if not self.enable_capture_offload: + return ExecuteOffloadResult(offloaded=False, response=await self.aexecute(command, timeout=timeout)) + cap = self._prepare_capture(capture_path, max_capture_bytes) + result = await self._bash.execute( + command, + timeout_seconds=self._timeout_seconds(timeout), + output_file=capture_path, + max_output_file_bytes=cap, + ) + return self._offload_result(result, capture_path, cap, max_inline_bytes) + + def _prepare_capture(self, capture_path: str, max_capture_bytes: int | None) -> int: + self._bash.mkdir(posixpath.dirname(capture_path), recursive=True) + return _CAPTURE_MAX_BYTES if max_capture_bytes is None else max(0, max_capture_bytes) + + def _offload_result(self, result, capture_path: str, cap: int, max_inline_bytes: int) -> ExecuteOffloadResult: + try: + data = self._bash.fs().read_file(capture_path) + except (RuntimeError, ValueError): + # The tee never started (e.g. unusable path): report the + # in-memory result as complete output instead. + return ExecuteOffloadResult(offloaded=False, response=self._execute_response(result)) + error = f"\nError: {result.error}" if result.error and result.error.encode() not in data else "" + if len(data) <= max_inline_bytes: + self._bash.remove(capture_path) + output = data.decode("utf-8", errors="replace") + error + return ExecuteOffloadResult( + offloaded=False, + response=ExecuteResponse(output=output, exit_code=result.exit_code, truncated=False), + ) + capped = len(data) >= cap + preview, has_marker = _capture_preview(data, capped=capped) + return ExecuteOffloadResult( + offloaded=True, + response=ExecuteResponse(output=preview + error, exit_code=result.exit_code, truncated=capped), + preview_has_truncation_marker=has_marker, + ) + @staticmethod def _execute_response(result) -> ExecuteResponse: output = result.stdout + (result.stderr or "") @@ -441,6 +570,22 @@ def _read_binary(self, file_path: str, path: str, size: int) -> ReadResult: return ReadResult(error=str(exc)) return ReadResult(file_data={"content": base64.b64encode(raw).decode("ascii"), "encoding": "base64"}) + # BaseSandbox's async helpers run shell scripts through `aexecute`; + # route them to the direct VFS implementations instead. + async def als(self, path: str) -> LsResult: + return await asyncio.to_thread(self.ls, path) + + async def awrite(self, file_path: str, content: str) -> WriteResult: + return await asyncio.to_thread(self.write, file_path, content) + + async def aedit( + self, file_path: str, old_string: str, new_string: str, replace_all: bool = False + ) -> EditResult: + return await asyncio.to_thread(self.edit, file_path, old_string, new_string, replace_all) + + async def adelete(self, file_path: str) -> DeleteResult: + return await asyncio.to_thread(self.delete, file_path) + async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult: return await self._run_cancellable(self.read, file_path, offset, limit) diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index 5356ac7d1..4becf2d6c 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -4374,12 +4374,36 @@ fn build_python_output_callback( }) } +/// `(absolute VFS path, max bytes)` for `Execution::output_file`. +type OutputFileRequest = (std::path::PathBuf, usize); + +/// Default cap for `output_file`, matching Deep Agents' capture cap and the +/// default `FsLimits::max_file_size`. +const DEFAULT_OUTPUT_FILE_BYTES: usize = 10 * 1024 * 1024; + +fn output_file_request( + output_file: Option, + max_output_file_bytes: Option, +) -> PyResult> { + match (output_file, max_output_file_bytes) { + (Some(path), max) => Ok(Some(( + std::path::PathBuf::from(path), + max.unwrap_or(DEFAULT_OUTPUT_FILE_BYTES), + ))), + (None, Some(_)) => Err(PyValueError::new_err( + "max_output_file_bytes requires output_file", + )), + (None, None) => Ok(None), + } +} + async fn exec_bash_with_optional_output( bash: &mut Bash, commands: &str, on_output: Option, builtin_session: Option>, timeout: Option, + output_file: Option, ) -> PyResult { let mut execution_extensions = ExecutionExtensions::new(); if let Some(session) = builtin_session { @@ -4387,6 +4411,27 @@ async fn exec_bash_with_optional_output( let _ = execution_extensions.insert(session); } + if let Some((path, max_bytes)) = output_file { + if on_output.is_some() { + return Err(PyTypeError::new_err( + "output_file cannot be combined with on_output", + )); + } + let mut options = bashkit::ExecOptions::new().extensions(execution_extensions); + if let Some(timeout) = timeout { + options = options.timeout(timeout); + } + // `usize::MAX` is clamped to the sandbox stdout/stderr caps, so the + // returned ExecResult matches buffered execution; the file keeps the rest. + let result = bash + .execute(commands) + .options(options) + .capture(usize::MAX) + .output_file(path, max_bytes) + .await; + return Ok(py_exec_result_from_bash_result(result)); + } + let result = if let Some(on_output) = on_output { // Preserve explicit cancel() calls across execute* entry. Only clear // cancellation if an on_output failure introduced it for this call. @@ -5157,7 +5202,7 @@ impl PyBash { #[cfg(not(target_arch = "wasm32"))] // Python keyword arguments map 1:1 onto parameters here. #[allow(clippy::too_many_arguments)] - #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None, timeout_seconds=None))] + #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None, timeout_seconds=None, output_file=None, max_output_file_bytes=None))] fn execute<'py>( &self, py: Python<'py>, @@ -5167,8 +5212,11 @@ impl PyBash { stdout: Option>, stderr: Option>, timeout_seconds: Option, + output_file: Option, + max_output_file_bytes: Option, ) -> PyResult> { let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; + let output_file = output_file_request(output_file, max_output_file_bytes)?; self.reject_external_handler_reentry()?; let live_output = capture.is_some() || stdout.is_some() || stderr.is_some(); if live_output && on_output.is_some() { @@ -5209,6 +5257,7 @@ impl PyBash { stderr, builtin_session.expect("live output session"), timeout, + output_file, ) .await } else { @@ -5218,6 +5267,7 @@ impl PyBash { on_output, builtin_session, timeout, + output_file, ) .await } @@ -5243,15 +5293,18 @@ impl PyBash { /// same `Bash` instance. If the lock cannot be acquired within the timeout, /// a `RuntimeError` is raised. For concurrent workloads, use separate `Bash` /// instances per thread or use the async `execute()` method. - #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None))] + #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None, output_file=None, max_output_file_bytes=None))] fn execute_sync( &self, py: Python<'_>, commands: String, on_output: Option>, timeout_seconds: Option, + output_file: Option, + max_output_file_bytes: Option, ) -> PyResult { let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; + let output_file = output_file_request(output_file, max_output_file_bytes)?; self.reject_external_handler_sync_access()?; let builtin_session = capture_custom_builtin_session( py, @@ -5286,6 +5339,7 @@ impl PyBash { on_output, builtin_session, timeout, + output_file, ) .await }) @@ -5301,7 +5355,7 @@ impl PyBash { on_output: Option>, timeout_seconds: Option, ) -> PyResult { - let result = self.execute_sync(py, commands, on_output, timeout_seconds)?; + let result = self.execute_sync(py, commands, on_output, timeout_seconds, None, None)?; if result.exit_code != 0 { return Err(raise_bash_error(&result)); } @@ -5321,6 +5375,7 @@ impl PyBash { timeout_seconds: Option, ) -> PyResult> { let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; + let output_file: Option = None; self.reject_external_handler_reentry()?; let builtin_session = capture_custom_builtin_session( py, @@ -5340,6 +5395,7 @@ impl PyBash { on_output, builtin_session, timeout, + output_file, ) .await?; if result.exit_code != 0 { @@ -6396,7 +6452,7 @@ impl BashTool { #[cfg(not(target_arch = "wasm32"))] // Python keyword arguments map 1:1 onto parameters here. #[allow(clippy::too_many_arguments)] - #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None, timeout_seconds=None))] + #[pyo3(signature = (commands, on_output=None, *, capture=None, stdout=None, stderr=None, timeout_seconds=None, output_file=None, max_output_file_bytes=None))] fn execute<'py>( &self, py: Python<'py>, @@ -6406,8 +6462,11 @@ impl BashTool { stdout: Option>, stderr: Option>, timeout_seconds: Option, + output_file: Option, + max_output_file_bytes: Option, ) -> PyResult> { let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; + let output_file = output_file_request(output_file, max_output_file_bytes)?; self.reject_external_handler_reentry()?; let live_output = capture.is_some() || stdout.is_some() || stderr.is_some(); if live_output && on_output.is_some() { @@ -6448,6 +6507,7 @@ impl BashTool { stderr, builtin_session.expect("live output session"), timeout, + output_file, ) .await } else { @@ -6457,6 +6517,7 @@ impl BashTool { on_output, builtin_session, timeout, + output_file, ) .await } @@ -6474,15 +6535,18 @@ impl BashTool { /// /// Acquires async mutex with 30-second timeout. For concurrent workloads, /// use separate `BashTool` instances per thread or the async `execute()`. - #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None))] + #[pyo3(signature = (commands, on_output=None, *, timeout_seconds=None, output_file=None, max_output_file_bytes=None))] fn execute_sync( &self, py: Python<'_>, commands: String, on_output: Option>, timeout_seconds: Option, + output_file: Option, + max_output_file_bytes: Option, ) -> PyResult { let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; + let output_file = output_file_request(output_file, max_output_file_bytes)?; self.reject_external_handler_sync_access()?; let builtin_session = capture_custom_builtin_session( py, @@ -6516,6 +6580,7 @@ impl BashTool { on_output, builtin_session, timeout, + output_file, ) .await }) @@ -6531,7 +6596,7 @@ impl BashTool { on_output: Option>, timeout_seconds: Option, ) -> PyResult { - let result = self.execute_sync(py, commands, on_output, timeout_seconds)?; + let result = self.execute_sync(py, commands, on_output, timeout_seconds, None, None)?; if result.exit_code != 0 { return Err(raise_bash_error(&result)); } @@ -6551,6 +6616,7 @@ impl BashTool { timeout_seconds: Option, ) -> PyResult> { let timeout = timeout_seconds.map(parse_timeout_seconds).transpose()?; + let output_file: Option = None; self.reject_external_handler_reentry()?; let builtin_session = capture_custom_builtin_session( py, @@ -6570,6 +6636,7 @@ impl BashTool { on_output, builtin_session, timeout, + output_file, ) .await?; if result.exit_code != 0 { diff --git a/crates/bashkit-python/src/streaming.rs b/crates/bashkit-python/src/streaming.rs index 03377b284..3ffff6bff 100644 --- a/crates/bashkit-python/src/streaming.rs +++ b/crates/bashkit-python/src/streaming.rs @@ -203,6 +203,8 @@ pub(crate) fn register( Ok(()) } +// Mirrors the Python `execute()` keyword arguments one-to-one. +#[allow(clippy::too_many_arguments)] pub(crate) async fn execute( bash: &mut Bash, script: &str, @@ -211,6 +213,7 @@ pub(crate) async fn execute( stderr: Option>, session: Arc, timeout: Option, + output_file: Option<(std::path::PathBuf, usize)>, ) -> PyResult { session.check_cancelled()?; let mut extensions = ExecutionExtensions::new(); @@ -223,6 +226,9 @@ pub(crate) async fn execute( if let Some(limit) = capture { execution = execution.capture(limit); } + if let Some((path, max_bytes)) = output_file { + execution = execution.output_file(path, max_bytes); + } if let Some(callback) = stdout { let session = session.clone(); execution = execution.stdout_fn(move |bytes| { diff --git a/crates/bashkit-python/tests/test_deepagents_backend.py b/crates/bashkit-python/tests/test_deepagents_backend.py index 9271bc650..b2d2c1f7d 100644 --- a/crates/bashkit-python/tests/test_deepagents_backend.py +++ b/crates/bashkit-python/tests/test_deepagents_backend.py @@ -254,3 +254,74 @@ def test_transfer_errors_use_protocol_codes(backend): assert backend.download_files(["/missing"])[0].error == "file_not_found" assert backend.download_files(["/tmp"])[0].error == "is_directory" assert backend.upload_files([("/tmp", b"no")])[0].error == "is_directory" + + +def test_backend_is_base_sandbox_for_capture_offload(backend): + from deepagents.backends.sandbox import BaseSandbox + + assert isinstance(backend, BaseSandbox) and backend.enable_capture_offload + + +def test_execute_with_offload_keeps_large_output_in_vfs(backend): + command = ( + "export KEPT=1; cd /tmp; " + "for i in $(seq 1 30); do head -c 100000 /dev/zero | tr '\\0' x; echo; done; echo END >&2" + ) + result = backend.execute_with_offload(command, "/large_tool_results/call_1", max_inline_bytes=80_000) + assert result.offloaded and result.response.exit_code == 0 + assert result.preview_has_truncation_marker + assert "lines truncated" in result.response.output + assert len(result.response.output) < 10_000 + # The full 3 MB survives in the VFS, past the 1 MB in-memory stdout cap. + saved = backend.download_files(["/large_tool_results/call_1"])[0].content + assert len(saved) == 30 * 100_001 + 4 and saved.endswith(b"END\n") + assert backend.grep("END", "/large_tool_results").matches + # No subshell: shell state carries over to the next command. + assert backend.execute("echo $KEPT $PWD").output == "1 /tmp\n" + + +def test_execute_with_offload_inline_and_capped(backend): + small = backend.execute_with_offload( + "echo hi; echo err >&2; exit 3", "/large_tool_results/c2", max_inline_bytes=1000 + ) + assert not small.offloaded + assert small.response.output == "hi\nerr\n" and small.response.exit_code == 3 + assert backend.download_files(["/large_tool_results/c2"])[0].error == "file_not_found" + capped = backend.execute_with_offload( + "head -c 50000 /dev/zero | tr '\\0' y", "/large_tool_results/c3", max_inline_bytes=100, max_capture_bytes=5000 + ) + assert capped.offloaded and capped.response.truncated + assert "capture stopped at its 5000-byte limit" in capped.response.output + assert len(backend.download_files(["/large_tool_results/c3"])[0].content) == 5000 + + +async def test_aexecute_with_offload_and_timeout(backend): + result = await backend.aexecute_with_offload( + "seq 1 50000; sleep 5", "/large_tool_results/c4", max_inline_bytes=1000, timeout=1 + ) + assert result.offloaded and result.response.exit_code != 0 + assert "timeout" in result.response.output + + +def test_framework_offloads_large_execute_output(backend): + from deepagents import create_deep_agent + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + + class ScriptedModel(FakeMessagesListChatModel): + def bind_tools(self, tools, **kwargs): + return self + + command = "for i in $(seq 1 20); do head -c 100000 /dev/zero | tr '\\0' z; echo; done" + model = ScriptedModel( + responses=[ + AIMessage(content="", tool_calls=[{"name": "execute", "args": {"command": command}, "id": "big"}]), + AIMessage(content="done"), + ] + ) + agent = create_deep_agent(model=model, backend=backend) + response = agent.invoke({"messages": [{"role": "user", "content": "run"}]}) + tool = next(message for message in response["messages"] if message.type == "tool") + assert "large_tool_results" in str(tool.content) + files = backend.glob("**", "/large_tool_results").matches + assert files and files[0]["size"] == 20 * 100_001 diff --git a/crates/bashkit/src/execution.rs b/crates/bashkit/src/execution.rs index 788b7faf4..7d9307f2f 100644 --- a/crates/bashkit/src/execution.rs +++ b/crates/bashkit/src/execution.rs @@ -1,10 +1,21 @@ //! One execution builder, with explicit capture and async byte sinks. Engine //! and output pumps run on the caller's executor; no threads are required. +//! +//! Decision: [`Execution::output_file`] tees combined stdout+stderr into a VFS +//! file with its own byte cap instead of the stdout/stderr delivery caps. It is +//! the host-side equivalent of `{ script; } > file 2>&1`: the bytes land in the +//! sandbox filesystem (bounded by `FsLimits` and the cap), not in host memory, +//! so the in-memory output caps that protect the host do not apply to it. It +//! runs in the current shell, so no wrapper script, `eval` or subshell is +//! needed and shell state carries over to the next call. +use crate::fs::FileSystem; use crate::interpreter::pipe::{PIPE_CAPACITY, Pipe, ReadEnd, WriteEnd}; use crate::{Bash, ExecOptions, ExecResult, ExecutionBudget, ExecutionLimits, Result}; use std::future::{Future, IntoFuture}; +use std::path::{Path, PathBuf}; use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncWrite, AsyncWriteExt}; @@ -106,6 +117,39 @@ pub struct Execution<'a> { stderr: Option>, options: ExecOptions, cancel: Option>, + output_file: Option<(PathBuf, usize)>, +} + +/// Combined-output tee into a VFS file (see [`Execution::output_file`]). +struct OutputFile { + fs: Arc, + path: PathBuf, + max_bytes: usize, + written: AtomicUsize, + failed: AtomicBool, +} + +impl OutputFile { + /// Append the part of `chunk` that still fits under the cap. A filesystem + /// error (for example `FsLimits`) stops the tee; it never fails the + /// execution, matching how a full disk truncates a redirected file. + async fn append(&self, chunk: &[u8]) { + if chunk.is_empty() || self.failed.load(Ordering::Relaxed) { + return; + } + let Ok(start) = self + .written + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |w| { + (w < self.max_bytes).then(|| w + chunk.len().min(self.max_bytes - w)) + }) + else { + return; + }; + let n = chunk.len().min(self.max_bytes - start); + if self.fs.append_file(&self.path, &chunk[..n]).await.is_err() { + self.failed.store(true, Ordering::Relaxed); + } + } } impl<'a> Execution<'a> { @@ -118,6 +162,7 @@ impl<'a> Execution<'a> { stderr: None, options: ExecOptions::new(), cancel: None, + output_file: None, } } /// Capture at most this many bytes per stream, within sandbox output limits. @@ -164,6 +209,33 @@ impl<'a> Execution<'a> { self } + /// Also write combined stdout+stderr, in arrival order, to the VFS file at + /// absolute `path`, keeping at most `max_bytes`. + /// + /// The file is created (or truncated) before the script runs; its parent + /// directory must exist. Unlike [`capture`](Self::capture) and the sinks, + /// the file is bounded by `max_bytes` and the filesystem's `FsLimits`, not + /// by `ExecutionLimits::max_stdout_bytes`/`max_stderr_bytes`, because the + /// bytes stay inside the sandbox like a `> file 2>&1` redirect. A file + /// whose size equals `max_bytes` may have been cut short. Typical use: keep + /// large tool output on disk for later `grep` while returning a preview. + /// + /// ```no_run + /// # async fn run() -> bashkit::Result<()> { + /// let mut bash = bashkit::Bash::new(); + /// let result = bash + /// .execute("seq 1 100000; echo done >&2") + /// .output_file("/tmp/out.log", 10 * 1024 * 1024) + /// .await?; + /// assert_eq!(result.exit_code, 0); + /// # Ok(()) + /// # } + /// ``` + pub fn output_file(mut self, path: impl Into, max_bytes: usize) -> Self { + self.output_file = Some((path.into(), max_bytes)); + self + } + /// Invocation parameters, input and request-scoped extensions. pub fn options(mut self, options: ExecOptions) -> Self { self.options = options; @@ -183,6 +255,26 @@ impl<'a> Execution<'a> { } let timeout = self.bash.interpreter.limits().timeout; let session_cancel = self.bash.cancellation_token(); + let output_file = match self.output_file { + Some((path, max_bytes)) => { + if !path.is_absolute() { + return Err(crate::Error::Execution(format!( + "output file must be an absolute path: {}", + path.display() + ))); + } + let fs = self.bash.fs(); + fs.write_file(Path::new(&path), b"").await?; + Some(Arc::new(OutputFile { + fs, + path, + max_bytes, + written: AtomicUsize::new(0), + failed: AtomicBool::new(false), + })) + } + None => None, + }; let output = ExecutionOutput::new(self.capture); let mut options = self.options; // The observer acts as the routing-scope marker already honored by @@ -202,8 +294,13 @@ impl<'a> Execution<'a> { let driven = Box::pin(async { tokio::try_join!( engine, - Box::pin(pump(output.clone(), false, self.stdout)), - Box::pin(pump(output.clone(), true, self.stderr)), + Box::pin(pump( + output.clone(), + false, + self.stdout, + output_file.clone() + )), + Box::pin(pump(output.clone(), true, self.stderr, output_file.clone())), ) }); let completed = async { @@ -255,6 +352,7 @@ async fn pump( output: Arc, stderr: bool, mut sink: Option>, + output_file: Option>, ) -> Result<(Vec, bool)> { let pipe = if stderr { output.stderr.clone() @@ -270,6 +368,9 @@ async fn pump( if chunk.is_empty() { break; } + if let Some(file) = &output_file { + file.append(&chunk).await; + } // Sandbox output caps apply to byte delivery as well as capture. A // smaller explicit capture limit never limits the sink itself. let accepted = chunk diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index f93a0554f..84c0e8b4a 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -61,6 +61,7 @@ pub mod interactive_builtins_tests; pub mod make_substitution_tests; pub mod markkit_builtin_tests; pub mod memory_growth_security_tests; +pub mod output_file_tests; pub mod streaming_command_tests; pub mod tar_files_from_budget_tests; // Both assert named-IANA-zone behavior, which only exists with `tzdata`. diff --git a/crates/bashkit/tests/integration/output_file_tests.rs b/crates/bashkit/tests/integration/output_file_tests.rs new file mode 100644 index 000000000..a31a3dc96 --- /dev/null +++ b/crates/bashkit/tests/integration/output_file_tests.rs @@ -0,0 +1,94 @@ +//! `Execution::output_file`: combined output tee into a VFS file with its own +//! byte cap, independent of the in-memory stdout/stderr caps. + +use bashkit::{Bash, ExecutionLimits}; +use std::path::Path; + +#[tokio::test] +async fn output_file_keeps_output_beyond_stdout_cap_and_shell_state() { + let limits = ExecutionLimits::default().max_stdout_bytes(1024); + let mut bash = Bash::builder().limits(limits).build(); + let result = bash + .execute("export KEPT=1; cd /tmp; seq 1 20000; echo tail-marker >&2") + .capture(64) + .output_file("/tmp/out.log", 10 * 1024 * 1024) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + // In-memory delivery still honors the 1 KiB stdout cap. + assert!(result.stdout_truncated); + assert!(result.stdout.len() <= 64); + let file = bash + .fs() + .read_file(Path::new("/tmp/out.log")) + .await + .unwrap(); + let text = String::from_utf8(file).unwrap(); + assert!(text.starts_with("1\n2\n3\n")); + assert!(text.contains("\n20000\n")); + assert!(text.contains("tail-marker\n")); + assert!(text.len() > 100_000); + // Runs in the current shell: no subshell, state carries over. + let next = bash.exec("echo $KEPT $PWD").await.unwrap(); + assert_eq!(next.stdout, "1 /tmp\n"); +} + +#[tokio::test] +async fn output_file_respects_its_cap_and_truncates_previous_content() { + let mut bash = Bash::new(); + bash.exec("echo stale-content-that-is-long > /tmp/out.log") + .await + .unwrap(); + let result = bash + .execute("seq 1 1000; exit 3") + .output_file("/tmp/out.log", 100) + .await + .unwrap(); + assert_eq!(result.exit_code, 3); + let file = bash + .fs() + .read_file(Path::new("/tmp/out.log")) + .await + .unwrap(); + assert_eq!(file.len(), 100); + assert!(file.starts_with(b"1\n2\n")); +} + +#[tokio::test] +async fn output_file_is_bounded_by_fs_limits_without_failing_the_call() { + let fs_limits = bashkit::FsLimits::new().max_file_size(1000); + let fs = std::sync::Arc::new(bashkit::InMemoryFs::with_limits(fs_limits)); + let mut bash = Bash::builder().fs(fs).build(); + bash.exec("mkdir -p /tmp").await.unwrap(); + let result = bash + .execute("seq 1 10000; echo ok") + .output_file("/tmp/out.log", 1_000_000) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + let file = bash + .fs() + .read_file(Path::new("/tmp/out.log")) + .await + .unwrap(); + assert!(file.len() <= 1000); +} + +#[tokio::test] +async fn output_file_rejects_relative_and_missing_parent_paths() { + let mut bash = Bash::new(); + assert!( + bash.execute("echo hi") + .output_file("out.log", 10) + .await + .is_err() + ); + assert!( + bash.execute("echo hi") + .output_file("/missing/dir/out.log", 10) + .await + .is_err() + ); + // Session stays usable. + assert_eq!(bash.exec("echo ok").await.unwrap().stdout, "ok\n"); +} diff --git a/knowledge/foundations/architecture.md b/knowledge/foundations/architecture.md index 062d27532..71b619365 100644 --- a/knowledge/foundations/architecture.md +++ b/knowledge/foundations/architecture.md @@ -75,6 +75,15 @@ explicit UTF-8 boundary with `text()` or `text_lossy()`. Line-oriented consumers locate delimiters and split the authoritative byte buffer before lossy decoding; offsets into the cached lossy view are never byte offsets. +`Execution::output_file(path, max_bytes)` tees combined stdout+stderr, in +arrival order, into a VFS file created/truncated before the script runs. The +file is bounded by its own `max_bytes` and `FsLimits`, not by +`max_stdout_bytes`/`max_stderr_bytes`: the bytes stay in the sandbox like a +`{ script; } > file 2>&1` redirect, while `capture` and sinks keep the +in-memory caps. A filesystem error stops the tee without failing the call. It +runs in the current shell, so hosts get full output for later `grep` with no +wrapper script, `eval`, or subshell, and shell state carries over. + Shell words and variables remain text. Command substitution therefore removes NUL bytes, as Bash does because variables cannot contain NUL, then decodes the remaining bytes for expansion and strips trailing newlines. JSON tool results diff --git a/knowledge/log.md b/knowledge/log.md index af65c518a..911856e98 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -3,6 +3,7 @@ ## 2026-11-02 * **Per-call execution timeout**: `ExecOptions::timeout` caps one `exec_with_options` call at the smaller of it and `ExecutionLimits::timeout`; Python `execute*` methods expose it as `timeout_seconds`. See [Architecture](foundations/architecture.md#per-invocation-exec-state). +* **Output file tee**: `Execution::output_file` writes combined output into a VFS file under its own cap and `FsLimits`, and the Deep Agents backend uses it for capture-at-source offload without a subshell. See [Architecture](foundations/architecture.md#byte-native-streams), TM-DOS-058. * **Deep Agents backend refresh**: `BashkitBackend` targets Deep Agents 0.7.32+, accepts the per-command `timeout`, reads binary files as base64, and returns protocol error codes for transfers. See [Python Package](runtimes/python-package.md#deep-agents-backend). ## 2026-11-01 diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index 29142993a..f72238a50 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -453,6 +453,21 @@ whole-file base64 `FileData` capped at 500 KB, matching upstream sandboxes, so multimodal reads work. Upload/download failures map to the protocol's `FileOperationError` codes, with the native message kept for anything else. +`BashkitBackend` subclasses Deep Agents' `BaseSandbox` only because the +framework gates capture-at-source offload on that type; every inherited +shell/`python3` helper (including the async `als`/`awrite`/`aedit`) is +overridden with direct VFS code. `enable_capture_offload = True`, and +`execute_with_offload` uses the native `output_file` tee (10 MiB default cap, +the framework's) instead of the upstream bash wrapper, whose `( eval … )` +subshell would drop `cd`/`export` between calls. Output at or under the inline +budget is returned whole and the file removed; larger output stays at the +framework's `/large_tool_results/` path, beyond the 1 MB in-memory stdout +cap, with the same head/tail preview and clip notices as upstream. + +`Bash`/`BashTool` `execute` and `execute_sync` accept +`output_file=`/`max_output_file_bytes=` (absolute VFS path; 10 MiB default); +not combinable with `on_output`. + File operations use the live native VFS directly, avoiding shell output caps, newline insertion, option injection, and colon-delimited path parsing. They never access the host filesystem. Transfers preserve arbitrary bytes; writes diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 03bf97aa2..30d75929e 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -276,7 +276,7 @@ on default 2 MiB test threads (TM-DOS-044). | TM-DOS-055 | `split` file count amplification | `split -l 1 bigfile` creates one output file per line; bounded by `max_file_count` FS limit | FS limits (TM-DOS-006) | **MITIGATED** | | TM-DOS-056 | `source` self-recursion stack overflow | Script that sources itself recurses unboundedly | `source` shares the function call-depth counter; self-/mutual recursion hits `max_function_depth` with a clean error, not SIGABRT | **MITIGATED** | | TM-DOS-057 | `sleep` bypasses execution timeout | `sleep`, `(sleep N)`, `echo x \| sleep N`, `sleep N & wait`, `timeout N sleep N` all ignore `ExecutionLimits::timeout` | `Bash::exec_impl` wraps execution in `time_compat::timeout(limits.timeout, …)` on every target (a per-call `ExecOptions::timeout` is applied as `min` with it, so callers can only tighten the budget); native uses tokio and JS-host wasm uses `setTimeout` through `gloo-timers`. Non-JS wasm clamps its blocking sleep spin to the shared execution deadline and checks expiry after every interpreter poll, including a ready result. Synchronous wasm CPU work cannot otherwise yield to a host timer, so command, loop, parser-fuel, and memory limits remain its deterministic backstop. Browser regressions: `sleep yields to the host wall clock`, `timeout enforces a host wall-clock deadline`, `options: timeoutMs bounds a pending async builtin`; native unit coverage verifies the non-JS clamp. | **MITIGATED** | -| TM-DOS-058 | Single-builtin unbounded output | `seq 1 1000000` produces 1M lines despite command limit; single builtin call generates unbounded output (see also #648) | `ExecutionLimits::max_stdout_bytes` and `max_stderr_bytes` truncate captured output (defaults set in `ExecutionLimits::new()`); see #648 | **MITIGATED** | +| TM-DOS-058 | Single-builtin unbounded output | `seq 1 1000000` produces 1M lines despite command limit; single builtin call generates unbounded output (see also #648) | `ExecutionLimits::max_stdout_bytes` and `max_stderr_bytes` truncate captured output (defaults set in `ExecutionLimits::new()`); see #648. A host-requested `Execution::output_file` tee writes into the VFS instead, bounded by its own byte cap and `FsLimits` (`max_file_size`, `max_total_bytes`), never host memory | **MITIGATED** | | TM-DOS-059 | Parameter expansion replacement bomb | `${x//a/$(printf 'b%.0s' {1..1000})}` on large `x` amplifies output multiplicatively (10K × 1K = 10MB) | `max_total_variable_bytes` + `max_stdout_bytes` | **MITIGATED** | | TM-DOS-060 | Sparse array huge-index allocation | `arr[999999999]=x` could allocate ~1B empty slots if arrays are Vec-backed; negative indices could cause OOB | HashMap-based arrays; `max_array_entries` caps total entries | **MITIGATED** | | TM-DOS-061 | Snapshot function restore bypasses parser/function limits | Crafted snapshot restores functions exceeding the tenant's parser depth or function memory budget | Restored function source re-parsed with current `ExecutionLimits`; function memory budget re-applied before insertion | **MITIGATED** | diff --git a/site/src/content/apidocs/python.md b/site/src/content/apidocs/python.md index 07396e4b8..77c6871b7 100644 --- a/site/src/content/apidocs/python.md +++ b/site/src/content/apidocs/python.md @@ -94,7 +94,7 @@ Register a live byte command; requires native async execute(). ### `execute` ```python -Bash.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None, timeout_seconds: float | None = None) -> ExecResult +Bash.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None, timeout_seconds: float | None = None, output_file: str | None = None, max_output_file_bytes: int | None = None) -> ExecResult ``` Execute bash commands asynchronously. @@ -104,6 +104,7 @@ Execute bash commands asynchronously. - **`commands`** — Bash script to run (like ``bash -c "commands"``). - **`on_output`** — Optional callback receiving chunked ``(stdout, stderr)`` pairs during execution. Must be synchronous. - **`timeout_seconds`** — Optional wall-clock cap for this call only. It can only tighten the constructor ``timeout_seconds``, never extend it. +- **`output_file`** — Optional absolute VFS path that receives combined stdout+stderr, created or truncated first. Bounded by ``max_output_file_bytes`` (default 10 MiB) and filesystem limits, not the in-memory output caps. Not combinable with ``on_output``. Async ``custom_builtins`` callbacks run on the caller's active asyncio loop. @@ -123,7 +124,7 @@ world ### `execute_sync` ```python -Bash.execute_sync(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None) -> ExecResult +Bash.execute_sync(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None, output_file: str | None = None, max_output_file_bytes: int | None = None) -> ExecResult ``` Execute bash commands synchronously (blocking). @@ -669,7 +670,7 @@ Register a live byte command; requires native async execute(). ### `execute` ```python -BashTool.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None, timeout_seconds: float | None = None) -> ExecResult +BashTool.execute(commands: str, on_output: OutputHandler | None = None, capture: int | None = None, stdout: ByteSink | None = None, stderr: ByteSink | None = None, timeout_seconds: float | None = None, output_file: str | None = None, max_output_file_bytes: int | None = None) -> ExecResult ``` Execute bash commands asynchronously. @@ -691,7 +692,7 @@ True ### `execute_sync` ```python -BashTool.execute_sync(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None) -> ExecResult +BashTool.execute_sync(commands: str, on_output: OutputHandler | None = None, timeout_seconds: float | None = None, output_file: str | None = None, max_output_file_bytes: int | None = None) -> ExecResult ``` Execute bash commands synchronously (blocking).