""" Cloudflare-backed sandbox example for manual validation. This example mirrors the Modal and E2B extension runners. It supports: - Standard agent run (non-streaming and streaming). - Snapshot stop/resume round-trip verification. - PTY interactive session demo. - Cloud bucket mount demo (R2/S3/GCS via CloudflareBucketMountStrategy). """ from __future__ import annotations import argparse import asyncio import io import os import sys import tempfile from pathlib import Path from typing import cast from openai.types.responses import ResponseTextDeltaEvent from agents import ModelSettings, Runner, set_tracing_disabled from agents.run import RunConfig from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig from agents.sandbox.capabilities import Shell from agents.sandbox.entries import File, R2Mount, S3Mount 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, tool_call_name try: from agents.extensions.sandbox import ( CloudflareBucketMountStrategy, CloudflareSandboxClient, CloudflareSandboxClientOptions, ) except Exception as exc: # pragma: no cover - import path depends on optional extras raise SystemExit( "Cloudflare sandbox examples require the optional repo extra.\n" "Install it with: uv sync --extra cloudflare" ) from exc DEFAULT_MODEL = "gpt-5.6-sol" DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences." DEFAULT_PTY_QUESTION = ( "Start an interactive Python session with `tty=true`. In that same session, compute " "`5 + 5`, then add 5 more to the previous result. Briefly report the outputs and " "confirm that you stayed in one Python process." ) SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt") SNAPSHOT_CHECK_CONTENT = "cloudflare snapshot round-trip ok\n" def _build_manifest( *, native_cloud_bucket_name: str | None = None, native_cloud_bucket_mount_path: str | None = None, native_cloud_bucket_endpoint_url: str | None = None, ) -> Manifest: """Build a small demo manifest, optionally including a cloud bucket mount.""" manifest = text_manifest( { "README.md": ( "# Cloudflare Demo Workspace\n\n" "This workspace exists to validate the Cloudflare sandbox backend manually.\n" ), "incident.md": ( "# Incident\n\n" "- Customer: Fabrikam Retail.\n" "- Issue: delayed reporting rollout.\n" "- Primary blocker: incomplete security questionnaire.\n" ), "plan.md": ( "# Plan\n\n" "1. Close the questionnaire.\n" "2. Reconfirm the rollout date with the customer.\n" ), } ) if native_cloud_bucket_name is None: return manifest # Determine whether this looks like an R2 bucket (has account ID) or S3. account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID") if account_id: manifest.entries["cloud-bucket"] = R2Mount( bucket=native_cloud_bucket_name, account_id=account_id, access_key_id=os.environ.get("R2_ACCESS_KEY_ID"), secret_access_key=os.environ.get("R2_SECRET_ACCESS_KEY"), mount_path=Path(native_cloud_bucket_mount_path) if native_cloud_bucket_mount_path is not None else None, read_only=False, mount_strategy=CloudflareBucketMountStrategy(), ) else: manifest.entries["cloud-bucket"] = S3Mount( bucket=native_cloud_bucket_name, access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), endpoint_url=native_cloud_bucket_endpoint_url, mount_path=Path(native_cloud_bucket_mount_path) if native_cloud_bucket_mount_path is not None else None, read_only=False, mount_strategy=CloudflareBucketMountStrategy(), ) return manifest def _build_pty_manifest() -> Manifest: """Build a tiny manifest for the PTY demo.""" return Manifest( entries={ "README.md": File( content=( b"# Cloudflare PTY Agent Example\n\n" b"This workspace is used by the Cloudflare PTY demo.\n" ) ), } ) def _require_env(name: str) -> str: value = os.environ.get(name) if value: return value raise SystemExit(f"{name} must be set before running this example.") 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 # --------------------------------------------------------------------------- # Stop/resume snapshot round-trip # --------------------------------------------------------------------------- async def _verify_stop_resume(*, worker_url: str, api_key: str | None) -> None: """Create a sandbox, write a file, stop, resume, and verify the file persisted.""" client = CloudflareSandboxClient() manifest = text_manifest( { "README.md": "# Snapshot test\n", } ) options = CloudflareSandboxClientOptions(worker_url=worker_url, api_key=api_key) with tempfile.TemporaryDirectory(prefix="cf-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: " f"expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}" ) finally: await resumed_sandbox.aclose() print("snapshot round-trip ok") # --------------------------------------------------------------------------- # PTY demo # --------------------------------------------------------------------------- def _stream_event_banner(event_name: str, raw_item: object) -> str | None: _ = raw_item if event_name == "tool_called": return "[tool call]" if event_name == "tool_output": return "[tool output]" return None def _raw_item_call_id(raw_item: object) -> str | None: if isinstance(raw_item, dict): call_id = raw_item.get("call_id") or raw_item.get("id") else: call_id = getattr(raw_item, "call_id", None) or getattr(raw_item, "id", None) return call_id if isinstance(call_id, str) and call_id else None async def _run_pty_demo(*, model: str, worker_url: str, api_key: str | None) -> None: """Demonstrate PTY interaction: start an interactive Python process and continue it.""" agent = SandboxAgent( name="Cloudflare PTY Demo", model=model, instructions=( "Complete the task by interacting with the sandbox through the shell capability. " "Keep the final answer concise. " "Preserve process state when the task depends on it. If you start an interactive " "program, continue using that same process instead of launching a second one." ), default_manifest=_build_pty_manifest(), capabilities=[Shell()], model_settings=ModelSettings(tool_choice="required"), ) client = CloudflareSandboxClient() sandbox = await client.create( manifest=agent.default_manifest, options=CloudflareSandboxClientOptions(worker_url=worker_url, api_key=api_key), ) try: async with sandbox: result = Runner.run_streamed( agent, DEFAULT_PTY_QUESTION, run_config=RunConfig( sandbox=SandboxRunConfig(session=sandbox), workflow_name="Cloudflare PTY sandbox example", ), ) saw_text_delta = False saw_any_text = False tool_names_by_call_id: dict[str, str] = {} async for event in 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) saw_any_text = True continue if event.type != "run_item_stream_event": continue raw_item = event.item.raw_item banner = _stream_event_banner(event.name, raw_item) if banner is None: continue if saw_text_delta: print() saw_text_delta = False if event.name == "tool_called": t_name = tool_call_name(raw_item) call_id = _raw_item_call_id(raw_item) if call_id is not None and t_name: tool_names_by_call_id[call_id] = t_name if t_name: banner = f"{banner} {t_name}" elif event.name == "tool_output": call_id = _raw_item_call_id(raw_item) output_tool_name = tool_names_by_call_id.get(call_id or "") if output_tool_name: banner = f"{banner} {output_tool_name}" print(banner) if saw_text_delta: print() if not saw_any_text: print(result.final_output) finally: await client.delete(sandbox) # --------------------------------------------------------------------------- # Standard agent run (streaming / non-streaming) # --------------------------------------------------------------------------- async def main( *, model: str, question: str, worker_url: str, api_key: str | None, stream: bool, demo: str | None, skip_snapshot_check: bool, native_cloud_bucket_name: str | None, native_cloud_bucket_mount_path: str, native_cloud_bucket_endpoint_url: str | None, ) -> None: _require_env("OPENAI_API_KEY") # Handle dedicated demos. if demo == "pty": await _run_pty_demo(model=model, worker_url=worker_url, api_key=api_key) return # Snapshot stop/resume round-trip. if not skip_snapshot_check: await _verify_stop_resume(worker_url=worker_url, api_key=api_key) manifest = _build_manifest( native_cloud_bucket_name=native_cloud_bucket_name, native_cloud_bucket_mount_path=native_cloud_bucket_mount_path, native_cloud_bucket_endpoint_url=native_cloud_bucket_endpoint_url, ) agent = SandboxAgent( name="Cloudflare 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=[Shell()], model_settings=ModelSettings(tool_choice="required"), ) run_config = RunConfig( sandbox=SandboxRunConfig( client=CloudflareSandboxClient(), options=CloudflareSandboxClientOptions(worker_url=worker_url, api_key=api_key), ), workflow_name="Cloudflare sandbox example", ) 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() if __name__ == "__main__": set_tracing_disabled(True) parser = argparse.ArgumentParser( description="Run a Cloudflare sandbox agent with optional PTY, streaming, and snapshot demos." ) parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.") parser.add_argument( "--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.", ) parser.add_argument( "--worker-url", default=os.environ.get("CLOUDFLARE_SANDBOX_WORKER_URL"), help="Cloudflare Worker base URL. Defaults to CLOUDFLARE_SANDBOX_WORKER_URL.", ) parser.add_argument( "--api-key", default=os.environ.get("CLOUDFLARE_SANDBOX_API_KEY"), help="Optional bearer token for the worker. Defaults to CLOUDFLARE_SANDBOX_API_KEY.", ) parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.") parser.add_argument( "--demo", default=None, choices=["pty"], help="Run a standalone demo instead of the standard agent flow.", ) parser.add_argument( "--skip-snapshot-check", action="store_true", default=False, help="Skip the snapshot stop/resume round-trip verification.", ) parser.add_argument( "--native-cloud-bucket-name", default=None, help="Optional R2/S3 bucket name to mount with CloudflareBucketMountStrategy.", ) parser.add_argument( "--native-cloud-bucket-mount-path", default="cloud-bucket", help=( "Mount path for --native-cloud-bucket-name. Relative paths are resolved under the " "workspace root." ), ) parser.add_argument( "--native-cloud-bucket-endpoint-url", default=None, help="Optional endpoint URL for --native-cloud-bucket-name (S3 only).", ) args = parser.parse_args() if not args.worker_url: raise SystemExit( "A Cloudflare Worker URL is required. Pass --worker-url or set CLOUDFLARE_SANDBOX_WORKER_URL." ) asyncio.run( main( model=args.model, question=args.question, worker_url=args.worker_url, api_key=args.api_key, stream=args.stream, demo=args.demo, skip_snapshot_check=args.skip_snapshot_check, native_cloud_bucket_name=args.native_cloud_bucket_name, native_cloud_bucket_mount_path=args.native_cloud_bucket_mount_path, native_cloud_bucket_endpoint_url=args.native_cloud_bucket_endpoint_url, ) )