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
13 changes: 10 additions & 3 deletions crates/bashkit-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,9 +688,16 @@ assert backend.grep("print", "/tmp", glob="*.py").matches[0]["line"] == 1
middleware = backend.create_middleware() # Shares the same virtual filesystem
```

Uses the structured Deep Agents 0.7.13+ backend protocol. Shell execution reports
native output truncation; file operations preserve exact VFS contents. Configure
execution timeouts on the backend constructor.
Uses the structured Deep Agents 0.7.32+ backend protocol. Shell execution reports
native output truncation; file operations preserve exact VFS contents. The
constructor `timeout_seconds` is the execution budget; a per-command `timeout`
from the agent can shorten it but never extend it. Images, PDFs and other
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`).

`Bash`/`BashTool` `execute*` methods accept the same per-call cap directly:
`execute_sync("make test", timeout_seconds=10)`.

## API Reference

Expand Down
28 changes: 22 additions & 6 deletions crates/bashkit-python/bashkit/_bashkit.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -614,13 +614,16 @@ class Bash:
capture: int | None = None,
stdout: ByteSink | None = None,
stderr: ByteSink | None = None,
timeout_seconds: float | None = None,
) -> ExecResult:
"""Execute bash commands asynchronously.

Args:
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.

Async ``custom_builtins`` callbacks run on the caller's active asyncio
loop.
Expand All @@ -638,7 +641,9 @@ class Bash:
"""
...

def execute_sync(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult:
def execute_sync(
self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None
) -> ExecResult:
"""Execute bash commands synchronously (blocking).

Async external handlers use the instance's private worker loop.
Expand All @@ -658,7 +663,9 @@ class Bash:
"""
...

async def execute_or_throw(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult:
async def execute_or_throw(
self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None
) -> ExecResult:
"""Execute commands asynchronously; raise ``BashError`` on non-zero exit.

``on_output`` must be synchronous.
Expand All @@ -675,7 +682,9 @@ class Bash:
"""
...

def execute_sync_or_throw(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult:
def execute_sync_or_throw(
self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None
) -> ExecResult:
"""Execute commands synchronously; raise ``BashError`` on non-zero exit.

``on_output`` must be synchronous.
Expand Down Expand Up @@ -1251,6 +1260,7 @@ class BashTool:
capture: int | None = None,
stdout: ByteSink | None = None,
stderr: ByteSink | None = None,
timeout_seconds: float | None = None,
) -> ExecResult:
"""Execute bash commands asynchronously.

Expand All @@ -1268,7 +1278,9 @@ class BashTool:
"""
...

def execute_sync(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult:
def execute_sync(
self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None
) -> ExecResult:
"""Execute bash commands synchronously (blocking).

Async ``custom_builtins`` callbacks run on a private loop here.
Expand All @@ -1288,7 +1300,9 @@ class BashTool:
"""
...

async def execute_or_throw(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult:
async def execute_or_throw(
self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None
) -> ExecResult:
"""Execute commands asynchronously; raise ``BashError`` on non-zero exit.

``on_output`` must be synchronous.
Expand All @@ -1302,7 +1316,9 @@ class BashTool:
"""
...

def execute_sync_or_throw(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult:
def execute_sync_or_throw(
self, commands: str, on_output: OutputHandler | None = None, *, timeout_seconds: float | None = None
) -> ExecResult:
"""Execute commands synchronously; raise ``BashError`` on non-zero exit.

``on_output`` must be synchronous.
Expand Down
70 changes: 61 additions & 9 deletions crates/bashkit-python/bashkit/deepagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from __future__ import annotations

import asyncio
import base64
import posixpath
import threading
import time
Expand Down Expand Up @@ -56,6 +57,27 @@
_MAX_OPERATION_BYTES = 10_000_000
_DEFAULT_GREP_MATCHES = 1_000
_MAX_GREP_RESULT_BYTES = 100_000
# Mirrors deepagents' sandbox binary read cap (`MAX_BINARY_BYTES`): images, PDFs
# and other binary files are returned base64-encoded up to this size.
_MAX_BINARY_READ_BYTES = 500 * 1024


def _file_error_code(exc: Exception) -> str:
"""Map a native VFS error to deepagents' `FileOperationError` codes.

The protocol standardizes these so the model can act on them; anything
unrecognized keeps the native message.
"""
message = str(exc).lower()
if "is a directory" in message:
return "is_directory"
if "not found" in message or "no such file" in message or "not a directory" in message:
return "file_not_found"
if "permission" in message or "read-only" in message or "readonly" in message:
return "permission_denied"
if "invalid path" in message or "path traversal" in message:
return "invalid_path"
return str(exc)


class _BudgetExceeded(Exception):
Expand Down Expand Up @@ -103,7 +125,7 @@ def _lines(content: str):
SandboxBackendProtocol,
WriteResult,
)
from deepagents.backends.utils import compile_grep_include_glob
from deepagents.backends.utils import _get_backend_read_file_type, compile_grep_include_glob
from langchain.agents.middleware.types import AgentMiddleware
from langchain_core.tools import StructuredTool

Expand Down Expand Up @@ -304,15 +326,24 @@ def create_middleware(self) -> BashkitMiddleware:
# parsing must never silently corrupt file contents or discovered paths.
# Async protocol defaults dispatch these sync methods off the event loop.

# Deep Agents explicitly detects backends without per-call timeout support.
# Keep the keyword absent so the framework uses our constructor timeout.
def execute(self, command: str) -> ExecuteResponse: # type: ignore[override]
result = self._bash.execute_sync(command)
# Decision: a per-call `timeout` only tightens the constructor
# `timeout_seconds` (native `ExecOptions::timeout` takes the minimum),
# so a model-chosen value can never extend the sandbox budget. `None`
# and `0` (Deep Agents' "no timeout" hint) both keep the configured
# limit.
@staticmethod
def _timeout_seconds(timeout: int | None) -> float | None:
if timeout is None or timeout <= 0:
return None
return float(timeout)

def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
result = self._bash.execute_sync(command, timeout_seconds=self._timeout_seconds(timeout))
return self._execute_response(result)

async def aexecute(self, command: str) -> ExecuteResponse: # type: ignore[override]
async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: # noqa: ASYNC109
# Preserve caller-loop clients and ContextVars in runtime callbacks.
result = await self._bash.execute(command)
result = await self._bash.execute(command, timeout_seconds=self._timeout_seconds(timeout))
return self._execute_response(result)

@staticmethod
Expand Down Expand Up @@ -362,6 +393,8 @@ def read(
try:
path = self._path(file_path)
metadata = self._bash.stat(path)
if _get_backend_read_file_type(path) != "text":
return self._read_binary(file_path, path, metadata["size"])
budget = self._budget(_cancelled)
self._charge(budget, files=1, size=metadata["size"])
selected = []
Expand All @@ -373,7 +406,13 @@ def read(
selected.append(line)
except _BudgetExceeded:
return ReadResult(error="read resource budget exceeded")
except UnicodeDecodeError:
# Text-by-extension file holding non-UTF-8 bytes: return it as
# base64 like Deep Agents' own backends instead of failing.
return self._read_binary(file_path, path, metadata["size"])
except (RuntimeError, ValueError) as exc:
if "utf-8" in str(exc).lower():
return self._read_binary(file_path, path, metadata["size"])
return ReadResult(error=str(exc))
start_line = end_line = total_lines = next_offset = None
if selected:
Expand All @@ -389,6 +428,19 @@ def read(
next_offset=next_offset,
)

def _read_binary(self, file_path: str, path: str, size: int) -> ReadResult:
"""Return a whole binary file base64-encoded (multimodal reads)."""
if size > _MAX_BINARY_READ_BYTES:
limit = _MAX_BINARY_READ_BYTES
return ReadResult(
error=f"File '{file_path}': Binary file exceeds maximum preview size of {limit} bytes"
)
try:
raw = self._bash.fs().read_file(path)
except (RuntimeError, ValueError) as exc:
return ReadResult(error=str(exc))
return ReadResult(file_data={"content": base64.b64encode(raw).decode("ascii"), "encoding": "base64"})

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)

Expand Down Expand Up @@ -533,7 +585,7 @@ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
content = self._bash.fs().read_file(self._path(path))
responses.append(FileDownloadResponse(path=path, content=content))
except (RuntimeError, ValueError) as exc:
responses.append(FileDownloadResponse(path=path, error=str(exc)))
responses.append(FileDownloadResponse(path=path, error=_file_error_code(exc)))
return responses

def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
Expand All @@ -545,7 +597,7 @@ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadRespons
self._bash.fs().write_file(absolute, content)
responses.append(FileUploadResponse(path=path))
except (RuntimeError, ValueError) as exc:
responses.append(FileUploadResponse(path=path, error=str(exc)))
responses.append(FileUploadResponse(path=path, error=_file_error_code(exc)))
return responses

# === Utility ===
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Changelog = "https://github.com/everruns/bashkit/releases"

[project.optional-dependencies]
langchain = ["langchain-core>=0.3", "langchain-anthropic>=0.3"]
deepagents = ["deepagents>=0.7.13", "langchain-anthropic>=0.3"]
deepagents = ["deepagents>=0.7.32", "langchain-anthropic>=0.3"]
pydantic-ai = ["pydantic-ai>=1.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23", "pytest-timeout>=2.2"]

Expand Down
Loading
Loading