1024 lines
33 KiB
Markdown
1024 lines
33 KiB
Markdown
# LangSmith Sandbox
|
|
|
|
Sandboxed code execution for LangSmith. Run untrusted code safely in isolated containers.
|
|
|
|
> ⚠️ **Warning**: This module is experimental. Features and APIs may change, and breaking changes are expected as we iterate.
|
|
|
|
## Quick Start
|
|
|
|
```python
|
|
from langsmith.sandbox import SandboxClient
|
|
|
|
# Client uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment
|
|
client = SandboxClient()
|
|
|
|
# First, build a snapshot (defines the container image and root filesystem)
|
|
snapshot = client.create_snapshot(
|
|
"python-snapshot",
|
|
docker_image="python:3.12-slim",
|
|
fs_capacity_bytes=4 * 1024**3, # 4 GB
|
|
)
|
|
|
|
# Now create a sandbox from the snapshot and run code
|
|
with client.sandbox(snapshot_id=snapshot.id) as sb:
|
|
result = sb.run("python -c 'print(2 + 2)'")
|
|
print(result.stdout) # "4\n"
|
|
print(result.success) # True
|
|
|
|
# Or create a sandbox to keep
|
|
sb = client.create_sandbox(snapshot_id=snapshot.id)
|
|
result = sb.run("python -c 'print(2 + 2)'")
|
|
client.delete_sandbox(sb.name) # Don't forget to clean up when done
|
|
|
|
# Or use an existing sandbox by name
|
|
sb = client.get_sandbox(name="your-sandbox")
|
|
result = sb.run("python -c 'print(2 + 2)'")
|
|
```
|
|
|
|
The examples below assume `snapshot_id` is bound to a snapshot UUID obtained
|
|
from `client.create_snapshot(...)` (or `client.list_snapshots()` /
|
|
`client.get_snapshot(...)`). You only need to build a snapshot once; many
|
|
sandboxes can be created from the same `snapshot_id`.
|
|
|
|
## Installation
|
|
|
|
The sandbox module works out of the box for basic command execution (HTTP). For
|
|
**real-time output** (streaming, callbacks, and `timeout=0`), install the
|
|
optional dependency:
|
|
|
|
```bash
|
|
pip install 'langsmith[sandbox]'
|
|
```
|
|
|
|
This pulls in the `websockets` package. Without it, `sb.run()` falls back to
|
|
HTTP automatically.
|
|
|
|
## Configuration
|
|
|
|
The client automatically uses LangSmith environment variables:
|
|
|
|
```python
|
|
from langsmith.sandbox import SandboxClient
|
|
|
|
# Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY
|
|
client = SandboxClient()
|
|
|
|
# Or configure explicitly
|
|
client = SandboxClient(
|
|
api_endpoint="https://api.smith.langchain.com/v2/sandboxes",
|
|
api_key="your-api-key",
|
|
timeout=30.0,
|
|
)
|
|
```
|
|
|
|
## Running Commands
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
# Run a command
|
|
result = sb.run("echo 'Hello, World!'")
|
|
|
|
print(result.stdout) # "Hello, World!\n"
|
|
print(result.stderr) # ""
|
|
print(result.exit_code) # 0
|
|
print(result.success) # True
|
|
|
|
# Commands that fail return non-zero exit codes
|
|
result = sb.run("exit 1")
|
|
print(result.success) # False
|
|
print(result.exit_code) # 1
|
|
```
|
|
|
|
## Streaming Output
|
|
|
|
For long-running commands, you can stream output in real time. This requires
|
|
the `websockets` package (`pip install 'langsmith[sandbox]'`).
|
|
|
|
### Callbacks
|
|
|
|
The simplest way to get real-time output. Blocks until the command completes.
|
|
|
|
```python
|
|
import sys
|
|
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
result = sb.run(
|
|
"make build",
|
|
timeout=600,
|
|
on_stdout=lambda s: print(s, end=""),
|
|
on_stderr=lambda s: print(s, end="", file=sys.stderr),
|
|
)
|
|
print(f"\nBuild {'succeeded' if result.success else 'failed'}")
|
|
```
|
|
|
|
### Streaming with CommandHandle
|
|
|
|
For full control — access to the process handle, stream identity, kill, and
|
|
reconnection.
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
handle = sb.run("make build", timeout=600, wait=False)
|
|
|
|
print(f"Command ID: {handle.command_id}")
|
|
|
|
for chunk in handle:
|
|
prefix = "OUT" if chunk.stream == "stdout" else "ERR"
|
|
print(f"[{prefix}] {chunk.data}", end="")
|
|
|
|
result = handle.result
|
|
print(f"\nExit code: {result.exit_code}")
|
|
```
|
|
|
|
### Killing a Running Command
|
|
|
|
```python
|
|
import threading
|
|
import time
|
|
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
handle = sb.run("sleep 3600", timeout=7200, wait=False)
|
|
|
|
# Kill after 10 seconds from another thread
|
|
def kill_after(h, seconds):
|
|
time.sleep(seconds)
|
|
h.kill()
|
|
|
|
threading.Thread(target=kill_after, args=(handle, 10)).start()
|
|
|
|
for chunk in handle:
|
|
print(chunk.data, end="")
|
|
|
|
result = handle.result
|
|
print(f"Exit code: {result.exit_code}") # non-zero (killed)
|
|
```
|
|
|
|
### Sending Stdin Input
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
handle = sb.run(
|
|
"python -c 'name = input(\"Name: \"); print(f\"Hello {name}\")'",
|
|
timeout=30,
|
|
wait=False,
|
|
)
|
|
|
|
for chunk in handle:
|
|
if "Name:" in chunk.data:
|
|
handle.send_input("World\n")
|
|
print(chunk.data, end="")
|
|
|
|
result = handle.result
|
|
```
|
|
|
|
### Auto-Reconnect
|
|
|
|
`CommandHandle` (returned by `sb.run(wait=False)`) automatically
|
|
reconnects on transient disconnects — hot-reloads, network blips, etc. No user
|
|
code needed:
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
handle = sb.run("make build", timeout=600, wait=False)
|
|
|
|
# Auto-reconnects on transient errors (hot-reload, network blips)
|
|
for chunk in handle:
|
|
print(chunk.data, end="")
|
|
|
|
result = handle.result
|
|
```
|
|
|
|
For manual reconnection across process restarts:
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
handle = sb.run("make build", timeout=600, wait=False)
|
|
command_id = handle.command_id
|
|
|
|
# ... later, possibly in a different process ...
|
|
|
|
handle = sb.reconnect(command_id)
|
|
for chunk in handle:
|
|
print(chunk.data, end="")
|
|
result = handle.result
|
|
```
|
|
|
|
### No Timeout (`timeout=0`)
|
|
|
|
With WebSocket enabled, you can set `timeout=0` to let a command run
|
|
indefinitely with no server-side deadline. This works with both `wait=False`
|
|
and callbacks. Useful for long-lived processes like dev servers, file watchers,
|
|
or background tasks that you control via `kill()`.
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
handle = sb.run("python server.py", timeout=0, wait=False)
|
|
|
|
for chunk in handle:
|
|
print(chunk.data, end="")
|
|
if "Ready" in chunk.data:
|
|
break # server is up, do other work
|
|
|
|
handle.kill() # stop when done
|
|
```
|
|
|
|
> **Note:** `timeout=0` requires WebSocket support
|
|
> (`pip install 'langsmith[sandbox]'`). Without WebSocket, `run()` falls
|
|
> back to HTTP which has its own request-level timeout.
|
|
|
|
## Command Lifecycle & TTL
|
|
|
|
The sandbox daemon automatically manages command session lifecycles with two
|
|
timeout mechanisms:
|
|
|
|
### Session TTL (finished commands)
|
|
|
|
After a command finishes (exits), its session remains in memory for a TTL
|
|
period. During this window you can still reconnect to retrieve output. After the
|
|
TTL expires, the session is cleaned up and `reconnect()` will raise an error.
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
handle = sb.run("make build", wait=False)
|
|
command_id = handle.command_id
|
|
|
|
# Even after the command finishes, you can reconnect within the TTL window
|
|
handle = sb.reconnect(command_id)
|
|
result = handle.result
|
|
print(result.stdout)
|
|
|
|
# After TTL expires, reconnect raises SandboxOperationError
|
|
```
|
|
|
|
### Idle Timeout (running commands)
|
|
|
|
Running commands with no connected clients are killed after an idle timeout
|
|
(default: 5 minutes). The idle timer resets each time a client connects. This
|
|
prevents orphaned long-running processes from consuming resources indefinitely.
|
|
|
|
You can set a per-command idle timeout via the `idle_timeout` parameter.
|
|
Set to `-1` for no idle timeout (the command runs indefinitely until explicitly
|
|
killed or it exits on its own).
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
# Start a long-running command with a 30-minute idle timeout
|
|
handle = sb.run(
|
|
"python server.py",
|
|
timeout=0,
|
|
idle_timeout=1800,
|
|
wait=False,
|
|
)
|
|
|
|
# As long as a client is connected (iterating), the idle timer is paused
|
|
for chunk in handle:
|
|
print(chunk.data, end="")
|
|
if "Ready" in chunk.data:
|
|
break
|
|
|
|
# After disconnecting, the idle timer starts
|
|
# If no client reconnects within idle_timeout seconds, the process is killed
|
|
```
|
|
|
|
### Kill on Disconnect
|
|
|
|
By default, commands continue running after a client disconnects and can be
|
|
reconnected to later. Set `kill_on_disconnect=True` to kill the command
|
|
immediately when the last client disconnects:
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
# Command is killed as soon as the client disconnects
|
|
handle = sb.run(
|
|
"python server.py",
|
|
kill_on_disconnect=True,
|
|
wait=False,
|
|
)
|
|
|
|
for chunk in handle:
|
|
print(chunk.data, end="")
|
|
if "Ready" in chunk.data:
|
|
break
|
|
# Command is killed here when iteration stops and the WS disconnects
|
|
```
|
|
|
|
### Combining Lifecycle Options
|
|
|
|
All lifecycle parameters can be combined:
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
# Long-running task: 30-min idle timeout, 1-hour session TTL
|
|
handle = sb.run(
|
|
"python train.py",
|
|
timeout=0, # No command timeout
|
|
idle_timeout=1800, # Kill after 30min with no clients
|
|
ttl_seconds=3600, # Keep session for 1 hour after exit
|
|
wait=False,
|
|
)
|
|
|
|
# Fire-and-forget: no idle timeout, infinite TTL
|
|
handle = sb.run(
|
|
"python background_job.py",
|
|
timeout=0,
|
|
idle_timeout=-1, # Never kill due to idle
|
|
ttl_seconds=-1, # Keep session forever
|
|
wait=False,
|
|
)
|
|
```
|
|
|
|
## PTY (Pseudo-Terminal)
|
|
|
|
Set `pty=True` to allocate a pseudo-terminal for the command. This is useful
|
|
for interactive programs and commands that detect terminal capabilities:
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
# Run an interactive Python REPL with PTY
|
|
handle = sb.run("python", pty=True, wait=False)
|
|
|
|
for chunk in handle:
|
|
if ">>>" in chunk.data:
|
|
handle.send_input("print('hello')\n")
|
|
break
|
|
|
|
for chunk in handle:
|
|
if ">>>" in chunk.data:
|
|
handle.send_input("exit()\n")
|
|
break
|
|
|
|
result = handle.result
|
|
|
|
# Commands that require a TTY
|
|
result = sb.run("top -b -n 1", pty=True)
|
|
```
|
|
|
|
> **Note:** PTY mode merges stdout and stderr into a single stream (stdout).
|
|
> Only use PTY when the command requires it — most commands work fine without it.
|
|
|
|
## File Operations
|
|
|
|
Read and write files in the sandbox:
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
# Write a file
|
|
sb.write("/app/script.py", "print('Hello from file!')")
|
|
|
|
# Run the script
|
|
result = sb.run("python /app/script.py")
|
|
print(result.stdout) # "Hello from file!\n"
|
|
|
|
# Read a file (returns bytes)
|
|
content = sb.read("/app/script.py")
|
|
print(content.decode()) # "print('Hello from file!')"
|
|
|
|
# Write binary files
|
|
sb.write("/app/data.bin", b"\x00\x01\x02\x03")
|
|
```
|
|
|
|
## TCP Tunnel
|
|
|
|
Access any TCP service running inside a sandbox (databases, Redis, HTTP servers,
|
|
etc.) as if it were running on your local machine. The tunnel opens a local TCP
|
|
port and forwards connections through a multiplexed WebSocket to the target port
|
|
inside the sandbox.
|
|
|
|
Requires the `websockets` package (`pip install 'langsmith[sandbox]'`).
|
|
|
|
### Basic Usage — PostgreSQL
|
|
|
|
Build a snapshot from the `postgres:16` image. The entrypoint initializes and
|
|
starts Postgres automatically:
|
|
|
|
```python
|
|
import psycopg2
|
|
|
|
postgres_snapshot = client.create_snapshot(
|
|
"postgres-snapshot",
|
|
docker_image="postgres:16",
|
|
fs_capacity_bytes=4 * 1024**3,
|
|
)
|
|
|
|
sb = client.create_sandbox(snapshot_id=postgres_snapshot.id)
|
|
pg_handle = sb.run(
|
|
"POSTGRES_HOST_AUTH_METHOD=trust docker-entrypoint.sh postgres",
|
|
timeout=0,
|
|
wait=False,
|
|
)
|
|
import time; time.sleep(6) # wait for Postgres to initialize and start
|
|
|
|
try:
|
|
with sb.tunnel(remote_port=5432, local_port=25432) as t:
|
|
conn = psycopg2.connect(
|
|
host="127.0.0.1",
|
|
port=t.local_port,
|
|
user="postgres",
|
|
)
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT version()")
|
|
print(cursor.fetchone())
|
|
conn.close()
|
|
finally:
|
|
pg_handle.kill()
|
|
client.delete_sandbox(sb.name)
|
|
```
|
|
|
|
### Basic Usage — Redis
|
|
|
|
Build a snapshot from the `redis:7` image. Redis self-daemonizes:
|
|
|
|
```python
|
|
import redis
|
|
|
|
redis_snapshot = client.create_snapshot(
|
|
"redis-snapshot",
|
|
docker_image="redis:7",
|
|
fs_capacity_bytes=2 * 1024**3,
|
|
)
|
|
|
|
with client.sandbox(snapshot_id=redis_snapshot.id) as sb:
|
|
sb.run("redis-server --daemonize yes", timeout=10)
|
|
|
|
with sb.tunnel(remote_port=6379, local_port=26379) as t:
|
|
r = redis.Redis(host="127.0.0.1", port=t.local_port)
|
|
r.set("key", "value")
|
|
print(r.get("key")) # b"value"
|
|
```
|
|
|
|
### HTTP Services
|
|
|
|
Works with any TCP service. Start long-running services with `wait=False` and
|
|
`timeout=0` so they stay alive across commands:
|
|
|
|
```python
|
|
sb = client.create_sandbox(snapshot_id=snapshot_id)
|
|
http_handle = sb.run("python3 -m http.server 3000", timeout=0, wait=False)
|
|
import time; time.sleep(2)
|
|
|
|
try:
|
|
with sb.tunnel(remote_port=3000, local_port=13000) as t:
|
|
import urllib.request
|
|
resp = urllib.request.urlopen(f"http://127.0.0.1:{t.local_port}/")
|
|
print(resp.status) # 200
|
|
finally:
|
|
http_handle.kill()
|
|
client.delete_sandbox(sb.name)
|
|
```
|
|
|
|
### Multiple Tunnels
|
|
|
|
Open several tunnels simultaneously to different services:
|
|
|
|
```python
|
|
http_handle2 = sb.run("python3 -m http.server 3001", timeout=0, wait=False)
|
|
import time; time.sleep(1)
|
|
|
|
with sb.tunnel(remote_port=3000, local_port=23000) as t1, \
|
|
sb.tunnel(remote_port=3001, local_port=23001) as t2:
|
|
resp1 = urllib.request.urlopen(f"http://127.0.0.1:{t1.local_port}/")
|
|
resp2 = urllib.request.urlopen(f"http://127.0.0.1:{t2.local_port}/")
|
|
|
|
http_handle2.kill()
|
|
```
|
|
|
|
### Explicit Lifecycle
|
|
|
|
For notebooks or long-lived sessions where a context manager isn't convenient:
|
|
|
|
```python
|
|
t = sb.tunnel(remote_port=3000, local_port=23002)
|
|
|
|
print(t.local_port)
|
|
# ... use the tunnel as long as needed ...
|
|
|
|
t.close()
|
|
```
|
|
|
|
### Async Usage
|
|
|
|
```python
|
|
async with await client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
async with await sb.tunnel(remote_port=5432) as t:
|
|
conn = await asyncpg.connect(host="127.0.0.1", port=t.local_port)
|
|
```
|
|
|
|
## Service URLs
|
|
|
|
Access HTTP services running inside a sandbox without opening a TCP tunnel.
|
|
`service()` returns a `ServiceURL` object with a short-lived JWT that
|
|
auto-refreshes transparently. Built-in HTTP helpers inject the auth header
|
|
for you.
|
|
|
|
### Basic Usage
|
|
|
|
```python
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
# Start a web server inside the sandbox
|
|
handle = sb.run("python -m http.server 3000", timeout=0, wait=False)
|
|
import time; time.sleep(2)
|
|
|
|
# Get a service URL for port 3000
|
|
svc = sb.service(port=3000)
|
|
|
|
# Make requests — token is injected automatically
|
|
resp = svc.get("/")
|
|
print(resp.status_code) # 200
|
|
|
|
# POST with JSON body
|
|
resp = svc.post("/api/data", json={"key": "value"})
|
|
|
|
# Access the raw token or URLs directly
|
|
print(svc.token) # JWT (auto-refreshes near expiry)
|
|
print(svc.service_url) # base URL for programmatic access
|
|
print(svc.browser_url) # URL that sets a cookie in a browser
|
|
|
|
handle.kill()
|
|
```
|
|
|
|
### Custom Token TTL
|
|
|
|
Tokens default to 10 minutes. Set `expires_in_seconds` for longer or shorter
|
|
lifetimes (1 second to 24 hours):
|
|
|
|
```python
|
|
# Token valid for 1 hour
|
|
svc = sb.service(port=3000, expires_in_seconds=3600)
|
|
```
|
|
|
|
### Auto-Refresh
|
|
|
|
The `ServiceURL` object automatically refreshes its token before it expires.
|
|
You never need to worry about token rotation — just keep using the object:
|
|
|
|
```python
|
|
svc = sb.service(port=3000, expires_in_seconds=60)
|
|
|
|
# Even after 60 seconds, this still works — token refreshes transparently
|
|
resp = svc.get("/api/status")
|
|
```
|
|
|
|
### Async Usage
|
|
|
|
```python
|
|
async with await client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
svc = await sb.service(port=3000)
|
|
|
|
# Async HTTP helpers
|
|
resp = await svc.get("/api/data")
|
|
|
|
# Async accessors for auto-refreshing properties
|
|
token = await svc.get_token()
|
|
url = await svc.get_service_url()
|
|
```
|
|
|
|
## Snapshots
|
|
|
|
Snapshots are the starting point for every sandbox. They're built from Docker
|
|
images or captured from running sandboxes, and many sandboxes can share the
|
|
same snapshot.
|
|
|
|
### Build a Snapshot from a Docker Image
|
|
|
|
```python
|
|
from langsmith.sandbox import SandboxClient
|
|
|
|
client = SandboxClient()
|
|
|
|
# Build a snapshot — blocks until ready (default timeout=60s)
|
|
snapshot = client.create_snapshot(
|
|
"my-python-env",
|
|
docker_image="python:3.12-slim",
|
|
fs_capacity_bytes=4 * 1024**3, # 4 GB
|
|
)
|
|
|
|
# Create a sandbox from the snapshot (by ID)
|
|
with client.sandbox(snapshot_id=snapshot.id) as sb:
|
|
result = sb.run("python --version")
|
|
print(result.stdout)
|
|
|
|
# Or resolve by snapshot name — the server looks up the snapshot owned by
|
|
# your tenant and boots from it. Exactly one of snapshot_id or snapshot_name
|
|
# must be provided.
|
|
with client.sandbox(snapshot_name="my-python-env") as sb:
|
|
result = sb.run("python --version")
|
|
print(result.stdout)
|
|
```
|
|
|
|
### Capture a Running Sandbox
|
|
|
|
Install packages or prepare files on a running sandbox, then capture the
|
|
result as a reusable snapshot. The returned snapshot has `source_sandbox_id`
|
|
set to the sandbox it was captured from, and can be used as the
|
|
`snapshot_id` for any later `create_sandbox` / `sandbox(...)` call.
|
|
|
|
```python
|
|
sb = client.create_sandbox(snapshot_id=base_snapshot_id, name="setup-box")
|
|
sb.run("pip install numpy pandas scikit-learn", timeout=180)
|
|
sb.write("/opt/config.yaml", "model: gpt-5\n")
|
|
|
|
# Either form works; the instance method just forwards to the client.
|
|
snapshot = sb.capture_snapshot("ml-ready", timeout=300)
|
|
# snapshot = client.capture_snapshot(sb.name, "ml-ready", timeout=300)
|
|
print(snapshot.id, snapshot.source_sandbox_id)
|
|
|
|
sb.delete()
|
|
|
|
# Later: spin up sandboxes from the captured snapshot
|
|
with client.sandbox(snapshot_id=snapshot.id) as sb:
|
|
sb.run("python -c 'import numpy; print(numpy.__version__)'")
|
|
assert sb.read("/opt/config.yaml") == b"model: gpt-5\n"
|
|
```
|
|
|
|
> **Note:** `capture_snapshot` preserves only the **persistent filesystem**.
|
|
> Installed packages (under `/usr/local`, `/root`, `/opt`, the home
|
|
> directory, etc.) and files you wrote to those paths are kept. Running
|
|
> processes, open sockets, in-memory state, and anything under `/tmp`
|
|
> (which is a tmpfs) are **not** carried over — restart the processes you
|
|
> need in the new sandbox.
|
|
|
|
### Snapshot CRUD
|
|
|
|
```python
|
|
# List snapshots (server paginates with a default page size of 50)
|
|
snapshots = client.list_snapshots()
|
|
|
|
# Filter and paginate — all three kwargs are optional and independent.
|
|
# `limit` must be between 1 and 500 (inclusive); `offset` must be >= 0.
|
|
snapshots = client.list_snapshots(
|
|
name_contains="python", # case-insensitive substring match on name
|
|
limit=100,
|
|
offset=0,
|
|
)
|
|
|
|
# Get a snapshot by ID
|
|
snapshot = client.get_snapshot("550e8400-...")
|
|
|
|
# Delete a snapshot
|
|
client.delete_snapshot("550e8400-...")
|
|
|
|
# Build with longer timeout for large images
|
|
snapshot = client.create_snapshot(
|
|
"heavy-image",
|
|
docker_image="nvidia/cuda:12.0-devel-ubuntu22.04",
|
|
fs_capacity_bytes=16 * 1024**3,
|
|
timeout=600,
|
|
)
|
|
```
|
|
|
|
## Start / Stop
|
|
|
|
Snapshot-based sandboxes can be stopped and restarted. The sandbox files are
|
|
preserved across stop/start cycles.
|
|
|
|
```python
|
|
sb = client.create_sandbox(snapshot_id=snapshot.id, name="my-vm")
|
|
sb.run("echo 'hello' > /tmp/state.txt")
|
|
|
|
# Stop the sandbox (preserves sandbox files)
|
|
sb.stop()
|
|
|
|
# Later: restart it
|
|
sb.start() # blocks until ready (default timeout=120s)
|
|
|
|
result = sb.run("cat /tmp/state.txt")
|
|
assert result.stdout.strip() == "hello"
|
|
```
|
|
|
|
You can also use the client methods directly:
|
|
|
|
```python
|
|
client.stop_sandbox("my-vm")
|
|
sandbox = client.start_sandbox("my-vm")
|
|
```
|
|
|
|
## Sandbox Lifetime & TTL
|
|
|
|
Sandboxes follow a two-stage retention model anchored to **idle activity**
|
|
and the **`stopped`** state — there is no wall-clock "max lifetime" TTL:
|
|
|
|
- **`idle_ttl_seconds`** — Idle timeout. The launcher stops the sandbox
|
|
after this many seconds of inactivity (any command execution or file I/O
|
|
resets the timer). When omitted at creation, the server applies a default
|
|
of `600` seconds (10 minutes); pass `0` explicitly to disable the idle
|
|
stop and keep the sandbox running indefinitely.
|
|
- **`delete_after_stop_seconds`** — Stop-anchored deletion. Once a sandbox
|
|
enters the `stopped` state (either via the idle timer above or an explicit
|
|
`stop_sandbox` call), this timer starts. After the deadline passes, the
|
|
sandbox row and its filesystem clone are permanently deleted by a
|
|
server-side sweep. Pass `0` to disable stop-anchored deletion (manual
|
|
cleanup required); when omitted, the server applies its configured default
|
|
(typically 14 days).
|
|
|
|
Both values must be multiples of 60 (minute-resolution). The lifecycle is:
|
|
|
|
```
|
|
running ──(idle for idle_ttl_seconds)──▶ stopped ──(delete_after_stop_seconds)──▶ deleted
|
|
```
|
|
|
|
```python
|
|
# Default retention (server defaults: 10 min idle stop, 14 day delete)
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
result = sb.run("echo hello")
|
|
|
|
# Aggressive: stop after 5 min idle, delete 1 hour after stop
|
|
sb = client.create_sandbox(
|
|
snapshot_id=snapshot_id,
|
|
idle_ttl_seconds=300,
|
|
delete_after_stop_seconds=3600,
|
|
)
|
|
|
|
# Long-running: never auto-stop, delete 7 days after manual stop
|
|
sb = client.create_sandbox(
|
|
snapshot_id=snapshot_id,
|
|
idle_ttl_seconds=0,
|
|
delete_after_stop_seconds=604800,
|
|
)
|
|
|
|
# Inspect retention settings
|
|
print(sb.idle_ttl_seconds) # e.g. 300
|
|
print(sb.delete_after_stop_seconds) # e.g. 3600
|
|
print(sb.stopped_at) # None while running, ISO timestamp once stopped
|
|
```
|
|
|
|
### Updating retention on existing sandboxes
|
|
|
|
You can update either retention setting on a running or stopped sandbox.
|
|
Updating `delete_after_stop_seconds` on an already-stopped sandbox shifts
|
|
its deletion deadline (`stopped_at + delete_after_stop_seconds`):
|
|
|
|
```python
|
|
# Extend the idle stop to 30 minutes
|
|
sb = client.update_sandbox("my-sandbox", idle_ttl_seconds=1800)
|
|
|
|
# Push the deletion deadline out to 30 days after stop
|
|
sb = client.update_sandbox("my-sandbox", delete_after_stop_seconds=2592000)
|
|
|
|
# Disable both — sandbox keeps running and never auto-deletes
|
|
sb = client.update_sandbox(
|
|
"my-sandbox",
|
|
idle_ttl_seconds=0,
|
|
delete_after_stop_seconds=0,
|
|
)
|
|
```
|
|
|
|
> **Migration note (alpha):** the previous `ttl_seconds` (hard wall-clock
|
|
> TTL) and `expires_at` fields were removed. The hard TTL never reliably
|
|
> deleted stopped sandboxes; replace any usage with `idle_ttl_seconds` for
|
|
> stopping and `delete_after_stop_seconds` for deletion.
|
|
|
|
## Reusing Existing Sandboxes
|
|
|
|
Get a sandbox that's already running:
|
|
|
|
```python
|
|
# Create a sandbox (requires explicit cleanup)
|
|
sb = client.create_sandbox(snapshot_id=snapshot_id)
|
|
print(sb.name) # e.g., "sandbox-abc123"
|
|
|
|
# Later, get the same sandbox
|
|
sb = client.get_sandbox("sandbox-abc123")
|
|
result = sb.run("echo 'Still running!'")
|
|
|
|
# Clean up when done
|
|
client.delete_sandbox("sandbox-abc123")
|
|
```
|
|
|
|
## Async Sandbox Creation
|
|
|
|
By default, `create_sandbox()` blocks until the sandbox is ready. For
|
|
non-blocking creation, pass `wait_for_ready=False`:
|
|
|
|
```python
|
|
# Returns immediately with status="provisioning"
|
|
sb = client.create_sandbox(snapshot_id=snapshot_id, wait_for_ready=False)
|
|
print(sb.status) # "provisioning"
|
|
|
|
# Poll until ready using the lightweight status endpoint
|
|
sb = client.wait_for_sandbox(sb.name, timeout=120, poll_interval=1.0)
|
|
print(sb.status) # "ready"
|
|
|
|
# Now the sandbox is usable
|
|
result = sb.run("echo hello")
|
|
```
|
|
|
|
You can also poll manually for more control:
|
|
|
|
```python
|
|
sb = client.create_sandbox(snapshot_id=snapshot_id, wait_for_ready=False)
|
|
|
|
while True:
|
|
status = client.get_sandbox_status(sb.name)
|
|
if status.status == "ready":
|
|
sb = client.get_sandbox(sb.name)
|
|
break
|
|
if status.status == "failed":
|
|
print(f"Failed: {status.status_message}")
|
|
break
|
|
time.sleep(1)
|
|
```
|
|
|
|
> **Note:** Operations like `run()`, `write()`, and `read()` will raise
|
|
> `SandboxNotReadyError` if called on a sandbox that isn't ready yet.
|
|
|
|
## Async Support
|
|
|
|
Full async support for all operations:
|
|
|
|
```python
|
|
from langsmith.sandbox import AsyncSandboxClient
|
|
|
|
async def main():
|
|
async with AsyncSandboxClient() as client:
|
|
# Build a snapshot first
|
|
snapshot = await client.create_snapshot(
|
|
"async-python",
|
|
docker_image="python:3.12-slim",
|
|
fs_capacity_bytes=4 * 1024**3,
|
|
)
|
|
|
|
# Use the snapshot
|
|
async with await client.sandbox(snapshot_id=snapshot.id) as sb:
|
|
result = await sb.run("python -c 'print(1 + 1)'")
|
|
print(result.stdout) # "2\n"
|
|
|
|
await sb.write("/app/test.txt", "async content")
|
|
content = await sb.read("/app/test.txt")
|
|
print(content.decode())
|
|
```
|
|
|
|
### Async Streaming
|
|
|
|
```python
|
|
async with await client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
handle = await sb.run("make build", timeout=600, wait=False)
|
|
|
|
async for chunk in handle:
|
|
print(chunk.data, end="")
|
|
|
|
result = await handle.result
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
The module provides type-based exceptions with a `resource_type` attribute for specific handling:
|
|
|
|
```python
|
|
from langsmith.sandbox import (
|
|
SandboxClientError, # Base exception for all sandbox errors
|
|
ResourceCreationError, # Resource provisioning failed (check resource_type, error_type)
|
|
ResourceNotFoundError, # Resource doesn't exist (check resource_type)
|
|
ResourceTimeoutError, # Operation timed out (check resource_type)
|
|
SandboxNotReadyError, # Sandbox not ready for operations yet
|
|
SandboxConnectionError, # Network/WebSocket error
|
|
CommandTimeoutError, # Command exceeded its timeout (extends SandboxOperationError)
|
|
QuotaExceededError, # Quota limit reached
|
|
TunnelError, # Base for tunnel errors
|
|
TunnelPortNotAllowedError, # Port blocked by daemon allowlist
|
|
TunnelConnectionRefusedError, # Nothing listening on remote port
|
|
TunnelUnsupportedVersionError, # Client/daemon protocol mismatch
|
|
)
|
|
|
|
try:
|
|
with client.sandbox(snapshot_id=snapshot_id) as sb:
|
|
result = sb.run("sleep 999", timeout=10)
|
|
except CommandTimeoutError as e:
|
|
print(f"Command timed out: {e}")
|
|
except ResourceCreationError as e:
|
|
print(f"{e.resource_type} creation failed: {e}")
|
|
except ResourceNotFoundError as e:
|
|
print(f"{e.resource_type} not found: {e}")
|
|
except ResourceTimeoutError as e:
|
|
print(f"Timeout waiting for {e.resource_type}: {e}")
|
|
except SandboxConnectionError as e:
|
|
print(f"Connection error: {e}")
|
|
except SandboxClientError as e:
|
|
print(f"Error: {e}")
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### SandboxClient
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `sandbox(snapshot_id=None, *, snapshot_name=None, idle_ttl_seconds=None, delete_after_stop_seconds=None, ...)` | Create a sandbox (auto-deleted on context exit). Exactly one of `snapshot_id` / `snapshot_name` must be set. |
|
|
| `create_sandbox(snapshot_id=None, *, snapshot_name=None, wait_for_ready=True, ...)` | Create a sandbox (requires explicit delete). Exactly one of `snapshot_id` / `snapshot_name` must be set. |
|
|
| `get_sandbox(name)` | Get an existing sandbox by name |
|
|
| `get_sandbox_status(name)` | Get lightweight provisioning status (`ResourceStatus`) |
|
|
| `wait_for_sandbox(name, *, timeout=120, poll_interval=1.0)` | Poll until sandbox is ready or failed |
|
|
| `service(name, port, *, expires_in_seconds=600)` | Get a `ServiceURL` for an HTTP service on the given port |
|
|
| `list_sandboxes()` | List all sandboxes |
|
|
| `update_sandbox(name, *, new_name=None, idle_ttl_seconds=None, delete_after_stop_seconds=None)` | Update a sandbox's name or retention settings |
|
|
| `delete_sandbox(name)` | Delete a sandbox |
|
|
| `start_sandbox(name, *, timeout=120)` | Start a stopped sandbox, poll until ready |
|
|
| `stop_sandbox(name)` | Stop a running sandbox (preserves sandbox files) |
|
|
| `create_snapshot(name, docker_image, fs_capacity_bytes, *, timeout=60)` | Build a snapshot from a Docker image |
|
|
| `capture_snapshot(sandbox_name, name, *, timeout=60)` | Capture a snapshot from a running sandbox |
|
|
| `get_snapshot(snapshot_id)` | Get a snapshot by ID |
|
|
| `list_snapshots(*, name_contains=None, limit=None, offset=None)` | List a page of snapshots (server paginates, default limit 50, max 500; `name_contains` is a case-insensitive substring match) |
|
|
| `delete_snapshot(snapshot_id)` | Delete a snapshot |
|
|
| `wait_for_snapshot(snapshot_id, *, timeout=300)` | Poll until snapshot is ready or failed |
|
|
|
|
### Sandbox
|
|
|
|
| Property | Description |
|
|
|----------|-------------|
|
|
| `name` | Display name |
|
|
| `snapshot_id` | Snapshot ID used to create this sandbox |
|
|
| `status` | Lifecycle status: `"provisioning"`, `"ready"`, `"failed"`, or `"stopped"` |
|
|
| `status_message` | Human-readable details when status is `"failed"`, `None` otherwise |
|
|
| `dataplane_url` | URL for runtime operations (only functional when status is `"ready"`) |
|
|
| `id` | Unique identifier (UUID) |
|
|
| `idle_ttl_seconds` | Idle timeout in seconds before the launcher stops the sandbox (`0` means disabled, `None` means not set). New sandboxes get a server-side default of `600` (10 minutes) when not explicitly provided. |
|
|
| `delete_after_stop_seconds` | Seconds after entering `stopped` before the sandbox and its filesystem clone are permanently deleted (`0` means disabled, `None` means server default). |
|
|
| `stopped_at` | ISO 8601 timestamp when the sandbox transitioned to `stopped`, or `None` while running. |
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `run(command, *, timeout=60, on_stdout=None, on_stderr=None, idle_timeout=300, kill_on_disconnect=False, ttl_seconds=600, pty=False, wait=True)` | Execute a shell command. Returns `ExecutionResult` or `CommandHandle` (when `wait=False`). |
|
|
| `reconnect(command_id, *, stdout_offset=0, stderr_offset=0)` | Reconnect to a running command. Returns `CommandHandle`. |
|
|
| `write(path, content)` | Write file (str or bytes) |
|
|
| `read(path)` | Read file (returns bytes) |
|
|
| `tunnel(remote_port, *, local_port=0)` | Open a TCP tunnel. Returns `Tunnel` (context manager). |
|
|
| `service(port, *, expires_in_seconds=600)` | Get a `ServiceURL` for an HTTP service. Auto-refreshes token. |
|
|
| `start(*, timeout=120)` | Start a stopped sandbox and wait until ready. |
|
|
| `stop()` | Stop a running sandbox (preserves sandbox files for later restart). |
|
|
| `delete()` | Delete this sandbox. |
|
|
| `capture_snapshot(name, *, timeout=60)` | Capture a snapshot from this sandbox. |
|
|
|
|
### ExecutionResult
|
|
|
|
| Property | Description |
|
|
|----------|-------------|
|
|
| `stdout` | Standard output (str) |
|
|
| `stderr` | Standard error (str) |
|
|
| `exit_code` | Exit code (int) |
|
|
| `success` | True if exit_code == 0 |
|
|
|
|
### ResourceStatus
|
|
|
|
Returned by `client.get_sandbox_status()`.
|
|
|
|
| Property | Description |
|
|
|----------|-------------|
|
|
| `status` | Lifecycle status: `"provisioning"`, `"ready"`, or `"failed"` |
|
|
| `status_message` | Human-readable details when `"failed"`, `None` otherwise |
|
|
|
|
### CommandHandle
|
|
|
|
Returned by `sb.run(wait=False)`. Iterable, yielding `OutputChunk` objects.
|
|
|
|
| Property / Method | Description |
|
|
|-------------------|-------------|
|
|
| `command_id` | Server-assigned command ID |
|
|
| `pid` | Process ID on the sandbox |
|
|
| `result` | Final `ExecutionResult` (blocks until complete) |
|
|
| `kill()` | Send SIGKILL to the running command |
|
|
| `send_input(data)` | Write string data to the command's stdin |
|
|
| `reconnect()` | Reconnect from last known offsets |
|
|
|
|
### OutputChunk
|
|
|
|
| Property | Description |
|
|
|----------|-------------|
|
|
| `stream` | `"stdout"` or `"stderr"` |
|
|
| `data` | Text content of this chunk (str) |
|
|
| `offset` | Byte offset within the stream (int) |
|
|
|
|
### Tunnel
|
|
|
|
Returned by `sb.tunnel(remote_port)`. Context manager that opens a local TCP
|
|
listener forwarding to a port inside the sandbox.
|
|
|
|
| Property / Method | Description |
|
|
|-------------------|-------------|
|
|
| `local_port` | Local port the tunnel is listening on (int) |
|
|
| `remote_port` | Target port inside the sandbox (int) |
|
|
| `close()` | Shut down the tunnel and all connections |
|
|
|
|
### ServiceURL
|
|
|
|
Returned by `sb.service(port)`. Holds a short-lived JWT for accessing an HTTP
|
|
service in the sandbox. Properties auto-refresh the token near expiry.
|
|
|
|
| Property | Description |
|
|
|----------|-------------|
|
|
| `token` | Raw JWT for programmatic use (auto-refreshes) |
|
|
| `service_url` | Base URL for programmatic HTTP access (auto-refreshes) |
|
|
| `browser_url` | URL that exchanges the JWT for a cookie in a browser (auto-refreshes) |
|
|
| `expires_at` | ISO 8601 expiration timestamp (auto-refreshes) |
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `request(method, path="/", **kwargs)` | Make an HTTP request with auth header injected. Returns `httpx.Response`. |
|
|
| `get(path="/", **kwargs)` | HTTP GET |
|
|
| `post(path="/", **kwargs)` | HTTP POST |
|
|
| `put(path="/", **kwargs)` | HTTP PUT |
|
|
| `patch(path="/", **kwargs)` | HTTP PATCH |
|
|
| `delete(path="/", **kwargs)` | HTTP DELETE |
|
|
|
|
`AsyncServiceURL` is the async variant. Use `await svc.get_token()`,
|
|
`await svc.get_service_url()`, etc. for auto-refreshing access, and
|
|
`await svc.get(path)` for async HTTP helpers.
|