ml-intern / agent /tools /sandbox_client.py
akseljoonas's picture
Revert "feat: stream sandbox bash output to frontend in real-time"
b94b18b
Raw
History Blame
25.7 kB
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["huggingface_hub>=0.20.0", "httpx>=0.27.0"]
# ///
"""
Sandbox Tools — Agent-native primitives for HF Space dev-mode sandboxes.
Architecture:
- Creates a sandbox by duplicating a template Space (runs sandbox_server.py)
- Waits for it to come online
- Communicates via HTTPS to the Space's API
- Optionally deletes the Space when done
Lifecycle:
sb = Sandbox.create(owner="burtenshaw") # duplicate, wait, connect
sb = Sandbox.create(owner="burtenshaw", # with options
hardware="t4-small",
private=True,
sleep_time=3600)
sb = Sandbox.connect("burtenshaw/my-sandbox-abc") # attach to existing
sb.bash("uv run train.py")
sb.read("/app/train.py")
sb.edit("/app/train.py", old_str="lr=1e-3", new_str="lr=1e-4")
sb.delete() # tear down when done
# Or use as a context manager for automatic cleanup
with Sandbox.create(owner="burtenshaw") as sb:
sb.bash("python train.py")
# Space deleted on exit
Tools: bash, read, write, edit, upload
"""
from __future__ import annotations
import io
import sys
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable
import httpx
from huggingface_hub import CommitOperationAdd, HfApi
TEMPLATE_SPACE = "burtenshaw/sandbox"
HARDWARE_OPTIONS = [
"cpu-basic",
"cpu-upgrade",
"t4-small",
"t4-medium",
"a10g-small",
"a10g-large",
"a100-large",
]
OUTPUT_LIMIT = 30000
LINE_LIMIT = 2000
DEFAULT_READ_LIMIT = 2000
DEFAULT_TIMEOUT = 120
MAX_TIMEOUT = 600
WAIT_TIMEOUT = 300
WAIT_INTERVAL = 5
API_WAIT_TIMEOUT = 180
_DOCKERFILE = """\
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
RUN apt-get update && \\
apt-get install -y \\
bash git git-lfs wget curl procps \\
htop vim nano jq tmux \\
build-essential && \\
rm -rf /var/lib/apt/lists/*
RUN uv pip install --system fastapi uvicorn python-multipart
RUN useradd -m -u 1000 user
USER user
ENV HOME=/home/user \\
PATH=/home/user/.local/bin:$PATH \\
PIP_USER=1 \\
HF_HUB_DISABLE_PROGRESS_BARS=1 \\
TQDM_DISABLE=1 \\
HF_HUB_ENABLE_HF_TRANSFER=1
WORKDIR /app
COPY --chown=user . /app
EXPOSE 7860
CMD ["python", "sandbox_server.py"]
"""
_SANDBOX_SERVER = '''\
"""Minimal FastAPI server for sandbox operations."""
import os, subprocess, pathlib
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional
import uvicorn
app = FastAPI()
class BashReq(BaseModel):
command: str
work_dir: str = "/app"
timeout: int = 120
class ReadReq(BaseModel):
path: str
offset: Optional[int] = None
limit: Optional[int] = 2000
class WriteReq(BaseModel):
path: str
content: str
class EditReq(BaseModel):
path: str
old_str: str
new_str: str
replace_all: bool = False
class ExistsReq(BaseModel):
path: str
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.post("/api/bash")
def bash(req: BashReq):
try:
r = subprocess.run(
req.command, shell=True, capture_output=True, text=True,
cwd=req.work_dir, timeout=req.timeout,
)
output = r.stdout + r.stderr
if len(output) > 30000:
output = output[:30000] + "\\n... (truncated)"
return {"success": r.returncode == 0, "output": output, "error": "" if r.returncode == 0 else f"Exit code {r.returncode}"}
except subprocess.TimeoutExpired:
return {"success": False, "output": "", "error": f"Timeout after {req.timeout}s"}
except Exception as e:
return {"success": False, "output": "", "error": str(e)}
@app.post("/api/read")
def read(req: ReadReq):
try:
p = pathlib.Path(req.path)
if not p.exists():
return {"success": False, "output": "", "error": f"File not found: {req.path}"}
if p.is_dir():
return {"success": False, "output": "", "error": f"Is a directory: {req.path}"}
lines = p.read_text().splitlines()
start = (req.offset or 1) - 1
end = start + (req.limit or len(lines))
selected = lines[start:end]
numbered = "\\n".join(f"{start + i + 1}\\t{line}" for i, line in enumerate(selected))
return {"success": True, "output": numbered, "error": ""}
except Exception as e:
return {"success": False, "output": "", "error": str(e)}
@app.post("/api/write")
def write(req: WriteReq):
try:
p = pathlib.Path(req.path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(req.content)
return {"success": True, "output": f"Wrote {len(req.content)} bytes to {req.path}", "error": ""}
except Exception as e:
return {"success": False, "output": "", "error": str(e)}
@app.post("/api/edit")
def edit(req: EditReq):
try:
p = pathlib.Path(req.path)
if not p.exists():
return {"success": False, "output": "", "error": f"File not found: {req.path}"}
content = p.read_text()
if req.old_str not in content:
return {"success": False, "output": "", "error": f"old_str not found in {req.path}"}
if not req.replace_all and content.count(req.old_str) > 1:
return {"success": False, "output": "", "error": f"old_str appears {content.count(req.old_str)} times. Use replace_all=true or provide more context."}
if req.replace_all:
new_content = content.replace(req.old_str, req.new_str)
else:
new_content = content.replace(req.old_str, req.new_str, 1)
p.write_text(new_content)
return {"success": True, "output": f"Edited {req.path}", "error": ""}
except Exception as e:
return {"success": False, "output": "", "error": str(e)}
@app.post("/api/exists")
def exists(req: ExistsReq):
return {"success": True, "output": str(pathlib.Path(req.path).exists()).lower(), "error": ""}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7860)
'''
@dataclass
class ToolResult:
success: bool
output: str = ""
error: str = ""
def __str__(self):
if self.success:
return self.output or "(no output)"
return f"ERROR: {self.error}"
def to_dict(self) -> dict:
return {"success": self.success, "output": self.output, "error": self.error}
@dataclass
class Sandbox:
"""
A handle to an HF Space sandbox.
Use Sandbox.create() to spin up a new one, or Sandbox.connect() to
attach to an existing running Space.
"""
space_id: str
token: str | None = None
work_dir: str = "/app"
timeout: int = DEFAULT_TIMEOUT
_owns_space: bool = field(default=False, repr=False)
_base_url: str = field(init=False, repr=False)
_client: httpx.Client = field(init=False, repr=False)
_hf_api: HfApi = field(init=False, repr=False)
_files_read: set = field(init=False, repr=False, default_factory=set)
def __post_init__(self):
slug = self.space_id.replace("/", "-")
# Trailing slash is critical: httpx resolves relative paths against base_url.
# Without it, client.get("health") resolves to /health instead of /api/health.
self._base_url = f"https://{slug}.hf.space/api/"
self._client = httpx.Client(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self.token}"} if self.token else {},
timeout=httpx.Timeout(MAX_TIMEOUT, connect=30),
follow_redirects=True,
)
self._hf_api = HfApi(token=self.token)
# ── Lifecycle ─────────────────────────────────────────────────
@classmethod
def create(
cls,
owner: str,
*,
name: str | None = None,
template: str = TEMPLATE_SPACE,
hardware: str = "cpu-basic",
private: bool = False,
sleep_time: int | None = None,
token: str | None = None,
wait_timeout: int = WAIT_TIMEOUT,
log: "Callable[[str], object] | None" = None,
) -> Sandbox:
"""
Create a new sandbox by duplicating the template Space.
Generates a unique space name, duplicates the template, waits for it
to come online, then returns a connected Sandbox.
Args:
owner: HF username or org (e.g. "burtenshaw").
name: Base name for the space. Defaults to "sandbox".
A unique suffix is always appended.
template: Source Space to duplicate (default: burtenshaw/sandbox).
hardware: Hardware tier (cpu-basic, t4-small, etc.).
private: Whether the Space should be private.
sleep_time: Auto-sleep after N seconds of inactivity.
token: HF API token (from user's OAuth session).
wait_timeout: Max seconds to wait for Space to start (default: 300).
Returns:
A Sandbox instance connected to the running Space.
"""
_log = log or print
api = HfApi(token=token)
base = name or "sandbox"
suffix = uuid.uuid4().hex[:8]
space_id = f"{owner}/{base}-{suffix}"
_log(f"Creating sandbox: {space_id} (from {template})...")
kwargs = {
"from_id": template,
"to_id": space_id,
"private": private,
"hardware": hardware,
}
if sleep_time is not None:
kwargs["sleep_time"] = sleep_time
api.duplicate_space(**kwargs)
_log(f"Space created: https://huggingface.co/spaces/{space_id}")
# Upload sandbox server and Dockerfile (triggers rebuild)
cls._setup_server(space_id, api, log=_log)
# Wait for it to come online (rebuild + start)
_log(f"Waiting for Space to start (timeout: {wait_timeout}s)...")
deadline = time.time() + wait_timeout
while time.time() < deadline:
runtime = api.get_space_runtime(space_id)
if runtime.stage == "RUNNING":
_log(f"Space is running (hardware: {runtime.hardware})")
break
if runtime.stage in ("RUNTIME_ERROR", "BUILD_ERROR"):
raise RuntimeError(
f"Space failed to start: {runtime.stage}. "
f"Check https://huggingface.co/spaces/{space_id}"
)
_log(f" {runtime.stage}...")
time.sleep(WAIT_INTERVAL)
else:
raise TimeoutError(
f"Space did not start within {wait_timeout}s. "
f"Check https://huggingface.co/spaces/{space_id}"
)
# Wait for the API server to be responsive (non-fatal)
sb = cls(space_id=space_id, token=token, _owns_space=True)
try:
sb._wait_for_api(timeout=API_WAIT_TIMEOUT, log=_log)
except TimeoutError as e:
_log(
f"Warning: API health check timed out ({e}), but Space is RUNNING. Continuing."
)
return sb
@staticmethod
def _setup_server(space_id: str, api: HfApi, *, log: Callable[[str], object] = print) -> None:
"""Upload embedded sandbox server + Dockerfile to the Space (single commit)."""
log(f"Uploading sandbox server to {space_id}...")
api.create_commit(
repo_id=space_id,
repo_type="space",
operations=[
CommitOperationAdd(
path_in_repo="sandbox_server.py",
path_or_fileobj=io.BytesIO(_SANDBOX_SERVER.encode()),
),
CommitOperationAdd(
path_in_repo="Dockerfile",
path_or_fileobj=io.BytesIO(_DOCKERFILE.encode()),
),
],
commit_message="Setup sandbox server",
)
log("Server files uploaded, rebuild triggered.")
@classmethod
def connect(cls, space_id: str, *, token: str | None = None) -> Sandbox:
"""
Connect to an existing running Space.
Does a health check to verify the Space is reachable.
"""
sb = cls(space_id=space_id, token=token, _owns_space=False)
sb._wait_for_api(timeout=60)
return sb
def _wait_for_api(self, timeout: int = API_WAIT_TIMEOUT, log: Callable[[str], object] = print):
"""Poll the health endpoint until the server responds."""
deadline = time.time() + timeout
last_err = None
last_status = None
while time.time() < deadline:
try:
resp = self._client.get("health", timeout=10)
last_status = resp.status_code
if resp.status_code == 200:
log(f"API is responsive at {self._base_url}")
return
except Exception as e:
last_err = e
time.sleep(3)
raise TimeoutError(
f"Sandbox API at {self._base_url} not responding after {timeout}s. "
f"Last status: {last_status}, last error: {last_err}"
)
def delete(self):
"""Delete the Space. Only works if this Sandbox created it."""
if not self._owns_space:
raise RuntimeError(
f"This Sandbox did not create {self.space_id}. "
f"Use self._hf_api.delete_repo() directly if you're sure."
)
print(f"Deleting sandbox: {self.space_id}...")
self._hf_api.delete_repo(self.space_id, repo_type="space")
self._client.close()
print("Deleted.")
def pause(self):
"""Pause the Space (stops billing, preserves state)."""
self._hf_api.pause_space(self.space_id)
def restart(self):
"""Restart the Space."""
self._hf_api.restart_space(self.space_id)
self._wait_for_api()
@property
def url(self) -> str:
"""Public URL of the Space."""
return f"https://huggingface.co/spaces/{self.space_id}"
@property
def status(self) -> str:
"""Current Space stage (RUNNING, BUILDING, PAUSED, etc.)."""
return self._hf_api.get_space_runtime(self.space_id).stage
def __enter__(self) -> Sandbox:
return self
def __exit__(self, *exc):
if self._owns_space:
try:
self.delete()
except Exception as e:
print(f"Warning: failed to delete sandbox: {e}", file=sys.stderr)
self._client.close()
# ── HTTP plumbing ─────────────────────────────────────────────
def _call(
self, endpoint: str, payload: dict, timeout: float | None = None
) -> ToolResult:
# Strip leading slash for correct httpx base_url resolution
endpoint = endpoint.lstrip("/")
try:
resp = self._client.post(
endpoint,
json=payload,
timeout=timeout or self.timeout,
)
data = resp.json()
if resp.status_code == 200:
return ToolResult(
success=data.get("success", True),
output=data.get("output", ""),
error=data.get("error", ""),
)
return ToolResult(
success=False,
error=data.get("error", f"HTTP {resp.status_code}"),
)
except httpx.TimeoutException:
return ToolResult(
success=False, error=f"Timeout after {timeout or self.timeout}s"
)
except httpx.ConnectError:
return ToolResult(
success=False,
error=f"Cannot connect to sandbox. Is {self.space_id} running? Status: {self.status}",
)
except Exception as e:
return ToolResult(success=False, error=str(e))
# ── Tools ─────────────────────────────────────────────────────
def bash(
self,
command: str,
*,
work_dir: str | None = None,
timeout: int | None = None,
description: str | None = None,
) -> ToolResult:
return self._call(
"bash",
{
"command": command,
"work_dir": work_dir or self.work_dir,
"timeout": min(timeout or self.timeout, MAX_TIMEOUT),
},
timeout=timeout,
)
def read(
self, path: str, *, offset: int | None = None, limit: int | None = None
) -> ToolResult:
self._files_read.add(path)
return self._call(
"read",
{
"path": path,
"offset": offset,
"limit": limit or (DEFAULT_READ_LIMIT if offset is None else None),
},
)
def write(self, path: str, content: str) -> ToolResult:
if path not in self._files_read:
check = self._call("exists", {"path": path})
if check.success and check.output == "true":
return ToolResult(
success=False,
error=(
f"File {path} exists but has not been read this session. "
f"Read it first, or use sandbox_edit for targeted changes."
),
)
result = self._call("write", {"path": path, "content": content})
if result.success:
self._files_read.add(path)
return result
def edit(
self, path: str, old_str: str, new_str: str, *, replace_all: bool = False
) -> ToolResult:
if old_str == new_str:
return ToolResult(success=False, error="old_str and new_str are identical.")
if path not in self._files_read:
return ToolResult(
success=False,
error=f"File {path} has not been read this session. Read it first.",
)
return self._call(
"edit",
{
"path": path,
"old_str": old_str,
"new_str": new_str,
"replace_all": replace_all,
},
)
# ── Tool schemas & dispatch ───────────────────────────────────
TOOLS = {
"bash": {
"description": (
"Run a shell command in the remote sandbox and return stdout/stderr.\n"
"\n"
"Commands run in a shell at the working directory (default /app). "
"Each invocation is independent — use files in /app to persist state.\n"
"\n"
"AVOID using bash for operations covered by specialized tools:\n"
"- File reading: use read (not cat/head/tail)\n"
"- File editing: use edit (not sed/awk)\n"
"- File writing: use write (not echo/cat <<EOF)\n"
"\n"
"For long-running tasks, background them:\n"
" nohup uv run train.py > /app/train.log 2>&1 &\n"
"Then check with read on the log file.\n"
"\n"
"Chain dependent commands with &&. Independent commands should be "
"separate bash calls (they can run in parallel).\n"
"\n"
"Timeout default 120s, max 600s."
),
"parameters": {
"type": "object",
"required": ["command"],
"additionalProperties": False,
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute.",
},
"description": {
"type": "string",
"description": "Short description (5-10 words, active voice). E.g. 'Install dependencies', 'Run training script'.",
},
"work_dir": {
"type": "string",
"description": "Working directory (default: /app).",
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 120, max: 600).",
},
},
},
},
"read": {
"description": (
"Read file contents with line numbers (cat -n format).\n"
"\n"
"Returns the first 2000 lines by default. For large files, use offset/limit "
"to read a specific range. Line numbers always match the original file.\n"
"\n"
"Lines longer than 2000 chars are truncated.\n"
"Cannot read directories — use bash with 'ls' instead."
),
"parameters": {
"type": "object",
"required": ["path"],
"additionalProperties": False,
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file to read.",
},
"offset": {
"type": "integer",
"description": "Start from this line (1-based). Only if file is too large.",
},
"limit": {
"type": "integer",
"description": "Number of lines to read. Only if file is too large.",
},
},
},
},
"write": {
"description": (
"Create or overwrite a file. Creates parent directories as needed.\n"
"\n"
"For existing files, you MUST read the file first (system enforced). "
"Prefer edit for modifications."
),
"parameters": {
"type": "object",
"required": ["path", "content"],
"additionalProperties": False,
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file to write.",
},
"content": {
"type": "string",
"description": "Complete file content.",
},
},
},
},
"edit": {
"description": (
"Targeted edit via exact string replacement.\n"
"\n"
"Rules:\n"
"- old_str must appear EXACTLY once (unless replace_all is true).\n"
"- Include enough context in old_str for uniqueness.\n"
"- old_str and new_str must differ.\n"
"- Preserve indentation exactly.\n"
"- To delete code, set new_str to empty string.\n"
"- File MUST have been read this session (system enforced).\n"
"- Do NOT include line number prefixes in old_str/new_str.\n"
"\n"
"Use replace_all=true for batch operations like variable renaming."
),
"parameters": {
"type": "object",
"required": ["path", "old_str", "new_str"],
"additionalProperties": False,
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file.",
},
"old_str": {
"type": "string",
"description": "Exact text to find (must differ from new_str).",
},
"new_str": {"type": "string", "description": "Replacement text."},
"replace_all": {
"type": "boolean",
"description": "Replace all occurrences (default: false).",
"default": False,
},
},
},
},
}
@classmethod
def tool_definitions(cls) -> list[dict]:
return [{"name": name, **spec} for name, spec in cls.TOOLS.items()]
def call_tool(self, name: str, arguments: dict[str, Any]) -> ToolResult:
dispatch = {
"bash": lambda a: self.bash(
a["command"],
work_dir=a.get("work_dir"),
timeout=a.get("timeout"),
description=a.get("description"),
),
"read": lambda a: self.read(
a["path"],
offset=a.get("offset"),
limit=a.get("limit"),
),
"write": lambda a: self.write(a["path"], a["content"]),
"edit": lambda a: self.edit(
a["path"],
a["old_str"],
a["new_str"],
replace_all=a.get("replace_all", False),
),
}
fn = dispatch.get(name)
if not fn:
return ToolResult(success=False, error=f"Unknown tool: {name}")
return fn(arguments)