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
45 changes: 43 additions & 2 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ jobs:
- name: Install wheel and test dependencies
run: |
pip install bashkit --no-index --find-links crates/bashkit-python/dist --force-reinstall
pip install pytest pytest-asyncio langchain-core langgraph fastapi httpx maturin
pip install pytest pytest-asyncio pytest-timeout langchain-core langgraph fastapi httpx maturin
pip install uvloop

- name: Install Deep Agents integration dependencies
if: matrix.python-version != '3.9' && matrix.python-version != '3.10'
Expand All @@ -118,7 +119,45 @@ jobs:

- name: Run tests
working-directory: crates/bashkit-python
run: pytest tests/ -v
run: pytest tests/ -v --timeout=30 --timeout-method=thread

# Loop ownership differs on Windows (Proactor) and Unix (Selector/uvloop).
# Test the native callback bridge on each platform instead of relying only
# on the Linux abi3 wheel matrix above. No random-FS fixture is needed here.
callback-platforms:
name: Callback loops (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- name: Build and install native wheel
run: |
python -m pip install "maturin${{ env.MATURIN_VERSION }}"
python -m maturin build -m crates/bashkit-python/Cargo.toml --out dist
python -m pip install bashkit --no-index --find-links dist
python -m pip install pytest pytest-asyncio pytest-timeout langchain-core deepagents fastapi httpx
- name: Install uvloop on Unix
if: runner.os != 'Windows'
run: python -m pip install uvloop
- name: Test callback execution models
run: >-
python -m pytest --timeout=30 --timeout-method=thread
crates/bashkit-python/tests/test_external_functions.py
crates/bashkit-python/tests/test_typescript_external_functions.py
crates/bashkit-python/tests/test_registered_tools.py
crates/bashkit-python/tests/test_async_callbacks.py
crates/bashkit-python/tests/test_jupyter_compat.py
crates/bashkit-python/tests/test_teardown_determinism.py

examples:
name: Examples
Expand Down Expand Up @@ -172,6 +211,8 @@ jobs:
run: |
jupyter nbconvert --to notebook --execute --ExecutePreprocessor.timeout=120 \
examples/jupyter_basics.ipynb --output /tmp/jupyter_basics_out.ipynb
jupyter nbconvert --to notebook --execute --ExecutePreprocessor.timeout=120 \
examples/runtime_external_functions.ipynb --output /tmp/runtime_external_functions_out.ipynb

# Verify wheel builds and passes twine check
build-wheel:
Expand Down
81 changes: 81 additions & 0 deletions crates/bashkit-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,87 @@ result = tool.execute_sync("echo 'Hello from BashTool'")
print(result.stdout)
```

## Python Function Calls

Register host functions for code running in embedded Python. Both `Bash` and
`BashTool` accept `external_functions` and an async `external_handler`:

```python
from bashkit import BashTool


async def handler(name, args, kwargs):
if name == "lookup":
return {"value": args[0] * kwargs["multiplier"]}
raise ValueError(f"Unknown function: {name}")


tool = BashTool(
python=True,
env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"},
external_functions=["lookup"],
external_handler=handler,
)
result = await tool.execute("python -c 'print(lookup(21, multiplier=2)[\"value\"])'")
assert result.stdout == "42\n"
```

LangChain's `create_bash_tool` / `BashkitTool` and deepagents' middleware/backend
accept the same options. `ainvoke()` and `BashkitBackend.aexecute()` run callbacks
on the caller's active asyncio loop, preserving loop-bound clients and request
ContextVars. Use these async APIs in FastAPI async endpoints or notebook cells
with top-level `await`.

Synchronous execution works from ordinary threads, FastAPI sync endpoints, and
notebook cells with an already-running loop. It drives callbacks on a dedicated
private worker loop shared across synchronous calls to that instance. Clients
bound to another loop must use the async API. ContextVars are captured for each
execution; a shared instance serializes requests. Timeouts and cancellation
cancel that execution's pending callback tasks.

If another handler is already waiting on the current loop, synchronous access
to that same interpreter fails immediately: blocking the loop would deadlock
the handler. Use the async API or move the synchronous operation to
`asyncio.to_thread()`.

Callbacks are trusted host code: enforce service authorization and I/O limits
there. They cannot re-enter their own interpreter or live filesystem handles;
pass data through arguments/results or use another instance. Function names and
handlers survive `reset()` and in-place restore. Snapshot bytes do not serialize
callbacks: supply them again to `from_snapshot()` / `from_snapshot_keyed()`.

Runnable notebook: [`runtime_external_functions.ipynb`](../../examples/runtime_external_functions.ipynb).

### TypeScript Function Calls

`BashTool` and its LangChain/deepagents adapters also accept separate
`typescript_external_functions` / `typescript_external_handler` options:

```python
async def ts_handler(name, args):
return {"value": args[0] * args[1]["multiplier"]}


tool = BashTool(
typescript=True,
typescript_external_functions=["lookup"],
typescript_external_handler=ts_handler,
)
result = await tool.execute("ts -c 'const r = await lookup(21, {multiplier: 2}); r.value'")
assert result.stdout == "42\n"
```

TypeScript dispatch receives `(name, args)`; use an object argument for named
options. Values cross as `None`/null, booleans, signed 64-bit integers, finite
floats, strings, lists, and string-keyed dictionaries. `undefined` arguments map
to `None`; function arguments, unsupported return objects, non-finite numbers,
and nesting deeper than 64 levels are rejected. Python and TypeScript handlers
can coexist, share the VFS, and use the same thread/loop/reset/snapshot rules.
Native host handlers are unavailable in the Pyodide build.

ZapCode currently loses `console.log()` output produced after an external call.
Return the final expression as shown above, or write results into the VFS.

## Persistent Custom Builtins

Use `custom_builtins={...}` on `Bash` or `BashTool` when you want
Expand Down
33 changes: 30 additions & 3 deletions crates/bashkit-python/bashkit/_bashkit.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ class FileSystem:
...

class ExternalHandler(Protocol):
"""Protocol for the external function handler passed to Bash.
"""Protocol for the external function handler passed to Bash or BashTool.

Called when Monty Python code invokes a registered external function.
Must be an async callable with this exact signature.
Expand All @@ -415,6 +415,11 @@ class ExternalHandler(Protocol):

async def __call__(self, fn_name: str, args: list[Any], kwargs: dict[str, Any]) -> Any: ...

class TypeScriptExternalHandler(Protocol):
"""Async host handler for TypeScript calls; arguments and results are JSON values."""

async def __call__(self, fn_name: str, args: list[Any]) -> Any: ...

class ShellState:
"""Read-only snapshot of shell state.

Expand Down Expand Up @@ -591,8 +596,8 @@ class Bash:
def execute_sync(self, commands: str, on_output: OutputHandler | None = None) -> ExecResult:
"""Execute bash commands synchronously (blocking).

Not supported when ``external_handler`` is configured — use
``execute()`` (async) instead. ``on_output`` must be synchronous.
Async external handlers use the instance's private worker loop.
Use ``await execute()`` for clients bound to the caller's running loop. ``on_output`` must be synchronous.
Async ``custom_builtins`` callbacks run on a private loop here.
When called from inside a running event loop (e.g. Jupyter / IPython),
callbacks are dispatched to a background thread with their own loop so
Expand Down Expand Up @@ -1116,6 +1121,10 @@ class BashTool:
profile: ExecutionProfile = ExecutionProfile.Standard,
python: bool = False,
typescript: bool = False,
external_functions: list[str] | None = None,
external_handler: ExternalHandler | None = None,
typescript_external_functions: list[str] | None = None,
typescript_external_handler: TypeScriptExternalHandler | None = None,
) -> None:
"""Create a new BashTool.

Expand Down Expand Up @@ -1150,6 +1159,16 @@ class BashTool:
profile: Execution profile (Standard or Compatibility).
python: Register sandboxed Monty Python (default ``False``).
typescript: Register sandboxed ZapCode TypeScript (default ``False``).
external_functions: Host function names callable from embedded Python.
external_handler: Async ``(name, args, kwargs)`` Python dispatcher.
typescript_external_functions: Host function names callable from TypeScript.
typescript_external_handler: Async ``(name, args)`` TypeScript dispatcher.

External handlers run on the caller's loop with ``await execute()`` and
on a dedicated private worker loop with ``execute_sync()``, including
inside notebooks. ContextVars are captured per execution. Handlers must
not re-enter this interpreter or its live filesystem handles. Reset
retains callbacks; snapshot factories require callbacks supplied again.

Embedded runtimes share the shell's virtual filesystem and stay enabled
after ``reset()``. Python additionally requires the host to provide
Expand Down Expand Up @@ -1431,6 +1450,10 @@ class BashTool:
profile: ExecutionProfile = ExecutionProfile.Standard,
python: bool = False,
typescript: bool = False,
external_functions: list[str] | None = None,
external_handler: ExternalHandler | None = None,
typescript_external_functions: list[str] | None = None,
typescript_external_handler: TypeScriptExternalHandler | None = None,
) -> BashTool:
"""Create a new ``BashTool`` from snapshot bytes and optional constructor kwargs."""
...
Expand All @@ -1456,6 +1479,10 @@ class BashTool:
profile: ExecutionProfile = ExecutionProfile.Standard,
python: bool = False,
typescript: bool = False,
external_functions: list[str] | None = None,
external_handler: ExternalHandler | None = None,
typescript_external_functions: list[str] | None = None,
typescript_external_handler: TypeScriptExternalHandler | None = None,
) -> BashTool:
"""Create a new ``BashTool`` from HMAC-protected snapshot bytes."""
...
Expand Down
52 changes: 47 additions & 5 deletions crates/bashkit-python/bashkit/deepagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@

from bashkit import BashTool as NativeBashTool

if TYPE_CHECKING:
from bashkit._bashkit import ExternalHandler, TypeScriptExternalHandler

# THREAT[TM-DOS-113]: Agent-facing VFS helpers bypass shell execution limits.
# Keep every direct walk and retained grep result independently bounded.
#
Expand Down Expand Up @@ -102,7 +105,7 @@ def _lines(content: str):
)
from deepagents.backends.utils import compile_grep_include_glob
from langchain.agents.middleware.types import AgentMiddleware
from langchain_core.tools import tool as langchain_tool
from langchain_core.tools import StructuredTool

DEEPAGENTS_AVAILABLE = True
except ImportError:
Expand All @@ -118,9 +121,7 @@ def _make_bash_tool(bash_instance: NativeBashTool, max_output_length: int = 100_
tool_name = bash_instance.name
tool_description = bash_instance.description()

@langchain_tool(tool_name, description=tool_description)
def bashkit(command: str) -> str:
result = bash_instance.execute_sync(command)
def format_result(result) -> str:
output = result.stdout
if result.error:
output += f"\nError: {result.error}"
Expand All @@ -133,7 +134,15 @@ def bashkit(command: str) -> str:
output = output[:max_output_length] + "\n[truncated]"
return output

return bashkit
def bashkit(command: str) -> str:
return format_result(bash_instance.execute_sync(command))

async def abashkit(command: str) -> str:
return format_result(await bash_instance.execute(command))

# Decision: provide a native coroutine. LangChain's default async wrapper
# runs the sync function in a worker, losing caller-loop callback affinity.
return StructuredTool.from_function(func=bashkit, coroutine=abashkit, name=tool_name, description=tool_description)


if DEEPAGENTS_AVAILABLE:
Expand Down Expand Up @@ -162,6 +171,10 @@ def __init__(
python: bool = False,
typescript: bool = False,
env: dict[str, str] | None = None,
external_functions: list[str] | None = None,
external_handler: ExternalHandler | None = None,
typescript_external_functions: list[str] | None = None,
typescript_external_handler: TypeScriptExternalHandler | None = None,
):
"""Initialize middleware.

Expand All @@ -176,6 +189,14 @@ def __init__(
typescript: Enable ZapCode TypeScript (ignored if bash_tool provided)
env: Initial environment for a new tool (ignored if bash_tool provided).
Python requires BASHKIT_ALLOW_INPROCESS_PYTHON=1 at construction.
external_functions: Python host function names (ignored if bash_tool provided).
external_handler: Async ``(name, args, kwargs)`` dispatcher
(ignored if bash_tool provided). Async tool calls use the
caller loop; sync calls use a private worker loop.
typescript_external_functions: TypeScript host function names
(ignored if bash_tool provided).
typescript_external_handler: Async ``(name, args)`` dispatcher
with JSON values (ignored if bash_tool provided).
"""
if bash_tool is not None:
self._bash = bash_tool
Expand All @@ -190,6 +211,10 @@ def __init__(
python=python,
typescript=typescript,
env=env,
external_functions=external_functions,
external_handler=external_handler,
typescript_external_functions=typescript_external_functions,
typescript_external_handler=typescript_external_handler,
)
self._owns_bash = True

Expand Down Expand Up @@ -239,6 +264,10 @@ def __init__(
python: bool = False,
typescript: bool = False,
env: dict[str, str] | None = None,
external_functions: list[str] | None = None,
external_handler: ExternalHandler | None = None,
typescript_external_functions: list[str] | None = None,
typescript_external_handler: TypeScriptExternalHandler | None = None,
):
self._bash = NativeBashTool(
username=username,
Expand All @@ -249,6 +278,10 @@ def __init__(
python=python,
typescript=typescript,
env=env,
external_functions=external_functions,
external_handler=external_handler,
typescript_external_functions=typescript_external_functions,
typescript_external_handler=typescript_external_handler,
)
self._id = f"bashkit-{uuid.uuid4().hex[:8]}"
self._operation_timeout = (
Expand All @@ -275,6 +308,15 @@ def create_middleware(self) -> BashkitMiddleware:
# 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)
return self._execute_response(result)

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

@staticmethod
def _execute_response(result) -> ExecuteResponse:
output = result.stdout + (result.stderr or "")
if result.error and result.error not in output:
output += f"\nError: {result.error}"
Expand Down
Loading
Loading