Files
openai--openai-agents-python/examples/sandbox/extensions/vercel_runner.py
T
2026-07-13 12:39:17 +08:00

425 lines
14 KiB
Python

"""
Minimal Vercel-backed sandbox example for manual validation.
This mirrors the other cloud extension examples: it creates a tiny workspace,
verifies stop/resume persistence, then asks a sandboxed agent to inspect the
workspace through one shell tool.
"""
from __future__ import annotations
import argparse
import asyncio
import io
import json
import os
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from typing import Literal, cast
from openai.types.responses import ResponseTextDeltaEvent
from agents import ModelSettings, Runner
from agents.models.openai_provider import OpenAIProvider
from agents.run import RunConfig
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.session import BaseSandboxSession
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from examples.sandbox.misc.example_support import text_manifest
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
try:
from agents.extensions.sandbox import VercelSandboxClient, VercelSandboxClientOptions
except Exception as exc: # pragma: no cover - import path depends on optional extras
raise SystemExit(
"Vercel sandbox examples require the optional repo extra.\n"
"Install it with: uv sync --extra vercel"
) from exc
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
SNAPSHOT_CHECK_CONTENT = "vercel snapshot round-trip ok\n"
LIVE_RESUME_CHECK_PATH = Path("live-resume-check.txt")
LIVE_RESUME_CHECK_CONTENT = "vercel live resume ok\n"
EXPOSED_PORT = 3000
PORT_CHECK_CONTENT = "<h1>vercel exposed port ok</h1>\n"
PORT_CHECK_NODE_SERVER_PATH = Path(".port-check-server.js")
PORT_CHECK_NODE_SERVER_CONTENT = f"""\
const http = require("node:http");
http
.createServer((_request, response) => {{
response.writeHead(200, {{"Content-Type": "text/html; charset=utf-8"}});
response.end({json.dumps(PORT_CHECK_CONTENT)});
}})
.listen({EXPOSED_PORT}, "0.0.0.0");
"""
PORT_CHECK_PYTHON_SERVER_PATH = Path(".port-check-server.py")
PORT_CHECK_PYTHON_SERVER_CONTENT = f"""\
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
body = {PORT_CHECK_CONTENT!r}.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None:
return
ThreadingHTTPServer(("0.0.0.0", {EXPOSED_PORT}), Handler).serve_forever()
"""
def _build_manifest() -> Manifest:
return text_manifest(
{
"README.md": (
"# Vercel Demo Workspace\n\n"
"This workspace exists to validate the Vercel sandbox backend manually.\n"
),
"handoff.md": (
"# Handoff\n\n"
"- Customer: Northwind Traders.\n"
"- Goal: validate Vercel sandbox exec and persistence flows.\n"
"- Current status: non-PTY backend slice is wired and under test.\n"
),
"todo.md": (
"# Todo\n\n"
"1. Inspect the workspace files.\n"
"2. Summarize the current status in two sentences.\n"
),
}
)
async def _read_text(session: BaseSandboxSession, path: Path) -> str:
data = await session.read(path)
text = cast(str | bytes, data.read())
if isinstance(text, bytes):
return text.decode("utf-8")
return text
def _require_env(name: str) -> None:
if os.environ.get(name):
return
raise SystemExit(f"{name} must be set before running this example.")
def _require_vercel_credentials() -> None:
if os.environ.get("VERCEL_OIDC_TOKEN"):
return
if (
os.environ.get("VERCEL_TOKEN")
and os.environ.get("VERCEL_PROJECT_ID")
and os.environ.get("VERCEL_TEAM_ID")
):
return
raise SystemExit(
"Vercel credentials are required. Set VERCEL_OIDC_TOKEN, or set "
"VERCEL_TOKEN together with VERCEL_PROJECT_ID and VERCEL_TEAM_ID."
)
async def _verify_stop_resume(
*,
manifest: Manifest,
runtime: str | None,
timeout_ms: int | None,
workspace_persistence: Literal["tar", "snapshot"],
) -> None:
client = VercelSandboxClient()
options = VercelSandboxClientOptions(
runtime=runtime,
timeout_ms=timeout_ms,
workspace_persistence=workspace_persistence,
)
with tempfile.TemporaryDirectory(prefix="vercel-snapshot-example-") as snapshot_dir:
sandbox = await client.create(
manifest=manifest,
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
options=options,
)
try:
await sandbox.start()
await sandbox.write(
SNAPSHOT_CHECK_PATH,
io.BytesIO(SNAPSHOT_CHECK_CONTENT.encode("utf-8")),
)
await sandbox.stop()
finally:
await sandbox.shutdown()
resumed_sandbox = await client.resume(sandbox.state)
try:
await resumed_sandbox.start()
restored_text = await _read_text(resumed_sandbox, SNAPSHOT_CHECK_PATH)
if restored_text != SNAPSHOT_CHECK_CONTENT:
raise RuntimeError(
f"Snapshot resume verification failed for {workspace_persistence!r}: "
f"expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}"
)
finally:
await resumed_sandbox.aclose()
print(f"snapshot round-trip ok ({workspace_persistence})")
async def _verify_resume_running_sandbox(
*,
manifest: Manifest,
runtime: str | None,
timeout_ms: int | None,
workspace_persistence: Literal["tar", "snapshot"],
) -> None:
client = VercelSandboxClient()
sandbox = await client.create(
manifest=manifest,
options=VercelSandboxClientOptions(
runtime=runtime,
timeout_ms=timeout_ms,
workspace_persistence=workspace_persistence,
),
)
try:
await sandbox.start()
await sandbox.write(
LIVE_RESUME_CHECK_PATH,
io.BytesIO(LIVE_RESUME_CHECK_CONTENT.encode("utf-8")),
)
serialized = client.serialize_session_state(sandbox.state)
resumed_sandbox = await client.resume(client.deserialize_session_state(serialized))
try:
restored_text = await _read_text(resumed_sandbox, LIVE_RESUME_CHECK_PATH)
if restored_text != LIVE_RESUME_CHECK_CONTENT:
raise RuntimeError(
"Running sandbox resume verification failed: "
f"expected {LIVE_RESUME_CHECK_CONTENT!r}, got {restored_text!r}"
)
finally:
await resumed_sandbox.aclose()
finally:
await sandbox.shutdown()
print(f"running sandbox resume ok ({workspace_persistence})")
def _fetch_url(url: str) -> str:
with urllib.request.urlopen(url, timeout=10) as response:
return cast(str, response.read().decode("utf-8"))
def _port_check_server_command() -> str:
node_path = PORT_CHECK_NODE_SERVER_PATH.as_posix()
python_path = PORT_CHECK_PYTHON_SERVER_PATH.as_posix()
return (
"if command -v node >/dev/null 2>&1; then "
f"node {node_path}; "
"elif command -v python3 >/dev/null 2>&1; then "
f"python3 {python_path}; "
"else "
"echo 'Neither node nor python3 is available for exposed port verification.' >&2; "
"exit 127; "
"fi >/tmp/vercel-http.log 2>&1 &"
)
async def _verify_exposed_port(
*,
manifest: Manifest,
runtime: str | None,
timeout_ms: int | None,
workspace_persistence: Literal["tar", "snapshot"],
) -> None:
client = VercelSandboxClient()
sandbox = await client.create(
manifest=manifest,
options=VercelSandboxClientOptions(
runtime=runtime,
timeout_ms=timeout_ms,
workspace_persistence=workspace_persistence,
exposed_ports=(EXPOSED_PORT,),
),
)
try:
await sandbox.start()
await sandbox.write(
PORT_CHECK_NODE_SERVER_PATH,
io.BytesIO(PORT_CHECK_NODE_SERVER_CONTENT.encode("utf-8")),
)
await sandbox.write(
PORT_CHECK_PYTHON_SERVER_PATH,
io.BytesIO(PORT_CHECK_PYTHON_SERVER_CONTENT.encode("utf-8")),
)
result = await sandbox.exec(
_port_check_server_command(),
shell=True,
)
if not result.ok():
raise RuntimeError(
f"Failed to start HTTP server for exposed port check: {result.stderr!r}"
)
endpoint = await sandbox.resolve_exposed_port(EXPOSED_PORT)
url = f"{'https' if endpoint.tls else 'http'}://{endpoint.host}:{endpoint.port}/"
last_error: Exception | None = None
for _ in range(20):
try:
body = await asyncio.to_thread(_fetch_url, url)
except (TimeoutError, urllib.error.URLError, ValueError) as exc:
last_error = exc
await asyncio.sleep(0.5)
continue
if PORT_CHECK_CONTENT.strip() not in body:
raise RuntimeError(f"Exposed port returned unexpected body from {url!r}: {body!r}")
print(f"exposed port ok ({workspace_persistence}) -> {url}")
return
raise RuntimeError(f"Exposed port verification failed for {url!r}") from last_error
finally:
await sandbox.shutdown()
async def main(
*,
model: str,
question: str,
runtime: str | None,
timeout_ms: int | None,
workspace_persistence: Literal["tar", "snapshot"],
stream: bool,
) -> None:
_require_env("OPENAI_API_KEY")
_require_vercel_credentials()
manifest = _build_manifest()
await _verify_stop_resume(
manifest=manifest,
runtime=runtime,
timeout_ms=timeout_ms,
workspace_persistence=workspace_persistence,
)
await _verify_resume_running_sandbox(
manifest=manifest,
runtime=runtime,
timeout_ms=timeout_ms,
workspace_persistence=workspace_persistence,
)
await _verify_exposed_port(
manifest=manifest,
runtime=runtime,
timeout_ms=timeout_ms,
workspace_persistence=workspace_persistence,
)
agent = SandboxAgent(
name="Vercel Sandbox Assistant",
model=model,
instructions=(
"Answer questions about the sandbox workspace. Inspect the files before answering "
"and keep the response concise. "
"Do not invent files or statuses that are not present in the workspace. Cite the "
"file names you inspected."
),
default_manifest=manifest,
capabilities=[WorkspaceShellCapability()],
model_settings=ModelSettings(tool_choice="required"),
)
client = VercelSandboxClient()
sandbox = await client.create(
manifest=manifest,
options=VercelSandboxClientOptions(
runtime=runtime,
timeout_ms=timeout_ms,
workspace_persistence=workspace_persistence,
),
)
run_config = RunConfig(
model_provider=OpenAIProvider(),
sandbox=SandboxRunConfig(session=sandbox),
# Disable tracing because it does not currently work reliably with alternate
# upstreams such as AI Gateway, and provider config already comes from env.
tracing_disabled=True,
workflow_name="Vercel sandbox example",
)
try:
async with sandbox:
if not stream:
result = await Runner.run(agent, question, run_config=run_config)
print(result.final_output)
return
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
saw_text_delta = False
async for event in stream_result.stream_events():
if event.type == "raw_response_event" and isinstance(
event.data, ResponseTextDeltaEvent
):
if not saw_text_delta:
print("assistant> ", end="", flush=True)
saw_text_delta = True
print(event.data.delta, end="", flush=True)
if saw_text_delta:
print()
finally:
await client.delete(sandbox)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="gpt-5.6-sol", help="Model name to use.")
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
parser.add_argument(
"--runtime",
default=None,
help="Optional Vercel runtime, for example `node22` or `python3.14`.",
)
parser.add_argument(
"--timeout-ms",
type=int,
default=120_000,
help="Optional Vercel sandbox timeout in milliseconds.",
)
parser.add_argument(
"--workspace-persistence",
choices=("tar", "snapshot"),
default="tar",
help="Workspace persistence mode to verify before the agent run.",
)
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
args = parser.parse_args()
asyncio.run(
main(
model=args.model,
question=args.question,
runtime=args.runtime,
timeout_ms=args.timeout_ms,
workspace_persistence=cast(Literal["tar", "snapshot"], args.workspace_persistence),
stream=args.stream,
)
)