367 lines
13 KiB
Python
367 lines
13 KiB
Python
"""
|
|
Minimal Modal-backed sandbox example for manual validation.
|
|
|
|
This example mirrors the local and Docker sandbox demos, but it sends the
|
|
workspace to a Modal sandbox.
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import io
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Literal, cast
|
|
|
|
from openai.types.responses import ResponseTextDeltaEvent
|
|
|
|
from agents import ModelSettings, Runner
|
|
from agents.run import RunConfig
|
|
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
|
|
from agents.sandbox.entries import GCSMount, Mount, 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
|
|
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
|
|
|
try:
|
|
from agents.extensions.sandbox import (
|
|
ModalCloudBucketMountStrategy,
|
|
ModalSandboxClient,
|
|
ModalSandboxClientOptions,
|
|
)
|
|
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
|
raise SystemExit(
|
|
"Modal sandbox examples require the optional repo extra.\n"
|
|
"Install it with: uv sync --extra modal"
|
|
) from exc
|
|
|
|
|
|
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
|
|
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
|
|
SNAPSHOT_CHECK_CONTENT = "modal snapshot round-trip ok\n"
|
|
MOUNT_CHECK_FILENAME = "native-cloud-bucket-check.txt"
|
|
MOUNT_CHECK_CONTENT = "modal native cloud bucket read/write ok\n"
|
|
MOUNT_CHECK_UPDATED_CONTENT = "modal native cloud bucket read/write ok after resume\n"
|
|
|
|
|
|
def _build_manifest(
|
|
*,
|
|
native_cloud_bucket_name: str | None = None,
|
|
native_cloud_bucket_provider: Literal["s3", "gcs-hmac"] = "s3",
|
|
native_cloud_bucket_mount_path: str | None = None,
|
|
native_cloud_bucket_endpoint_url: str | None = None,
|
|
native_cloud_bucket_key_prefix: str | None = None,
|
|
native_cloud_bucket_secret_name: str | None = None,
|
|
) -> Manifest:
|
|
manifest = text_manifest(
|
|
{
|
|
"README.md": (
|
|
"# Modal Demo Workspace\n\n"
|
|
"This workspace exists to validate the Modal 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
|
|
|
|
mount_path = (
|
|
Path(native_cloud_bucket_mount_path) if native_cloud_bucket_mount_path is not None else None
|
|
)
|
|
mount_strategy = ModalCloudBucketMountStrategy(
|
|
secret_name=native_cloud_bucket_secret_name,
|
|
)
|
|
if native_cloud_bucket_provider == "gcs-hmac":
|
|
manifest.entries["cloud-bucket"] = GCSMount(
|
|
bucket=native_cloud_bucket_name,
|
|
access_id=(
|
|
None
|
|
if native_cloud_bucket_secret_name is not None
|
|
else (
|
|
os.environ.get("GCS_HMAC_ACCESS_KEY_ID")
|
|
or os.environ.get("GOOGLE_ACCESS_KEY_ID")
|
|
)
|
|
),
|
|
secret_access_key=(
|
|
None
|
|
if native_cloud_bucket_secret_name is not None
|
|
else (
|
|
os.environ.get("GCS_HMAC_SECRET_ACCESS_KEY")
|
|
or os.environ.get("GOOGLE_ACCESS_KEY_SECRET")
|
|
)
|
|
),
|
|
endpoint_url=native_cloud_bucket_endpoint_url,
|
|
prefix=native_cloud_bucket_key_prefix,
|
|
mount_path=mount_path,
|
|
read_only=False,
|
|
mount_strategy=mount_strategy,
|
|
)
|
|
else:
|
|
manifest.entries["cloud-bucket"] = S3Mount(
|
|
bucket=native_cloud_bucket_name,
|
|
access_key_id=(
|
|
None
|
|
if native_cloud_bucket_secret_name is not None
|
|
else os.environ.get("AWS_ACCESS_KEY_ID")
|
|
),
|
|
secret_access_key=(
|
|
None
|
|
if native_cloud_bucket_secret_name is not None
|
|
else os.environ.get("AWS_SECRET_ACCESS_KEY")
|
|
),
|
|
session_token=(
|
|
None
|
|
if native_cloud_bucket_secret_name is not None
|
|
else os.environ.get("AWS_SESSION_TOKEN")
|
|
),
|
|
endpoint_url=native_cloud_bucket_endpoint_url,
|
|
prefix=native_cloud_bucket_key_prefix,
|
|
mount_path=mount_path,
|
|
read_only=False,
|
|
mount_strategy=mount_strategy,
|
|
)
|
|
return manifest
|
|
|
|
|
|
def _native_cloud_bucket_mount_path(manifest: Manifest) -> Path | None:
|
|
entry = manifest.entries.get("cloud-bucket")
|
|
if not isinstance(entry, Mount):
|
|
return None
|
|
if entry.mount_path is None:
|
|
return Path(manifest.root) / "cloud-bucket"
|
|
if entry.mount_path.is_absolute():
|
|
return entry.mount_path
|
|
return Path(manifest.root) / entry.mount_path
|
|
|
|
|
|
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.")
|
|
|
|
|
|
async def _verify_stop_resume(
|
|
*,
|
|
manifest: Manifest,
|
|
app_name: str,
|
|
workspace_persistence: Literal["tar", "snapshot_filesystem", "snapshot_directory"],
|
|
sandbox_create_timeout_s: float | None,
|
|
) -> None:
|
|
client = ModalSandboxClient()
|
|
mount_path = _native_cloud_bucket_mount_path(manifest)
|
|
mount_check_path = mount_path / MOUNT_CHECK_FILENAME if mount_path is not None else None
|
|
options = ModalSandboxClientOptions(
|
|
app_name=app_name,
|
|
workspace_persistence=workspace_persistence,
|
|
sandbox_create_timeout_s=sandbox_create_timeout_s,
|
|
)
|
|
with tempfile.TemporaryDirectory(prefix="modal-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"native cloud bucket read/write ok ({mount_check_path})")
|
|
print(f"snapshot round-trip ok ({workspace_persistence})")
|
|
|
|
|
|
async def main(
|
|
*,
|
|
model: str,
|
|
question: str,
|
|
app_name: str,
|
|
workspace_persistence: Literal["tar", "snapshot_filesystem", "snapshot_directory"],
|
|
sandbox_create_timeout_s: float | None,
|
|
native_cloud_bucket_name: str | None,
|
|
native_cloud_bucket_provider: Literal["s3", "gcs-hmac"],
|
|
native_cloud_bucket_mount_path: str,
|
|
native_cloud_bucket_endpoint_url: str | None,
|
|
native_cloud_bucket_key_prefix: str | None,
|
|
native_cloud_bucket_secret_name: str | None,
|
|
stream: bool,
|
|
) -> None:
|
|
_require_env("OPENAI_API_KEY")
|
|
manifest = _build_manifest(
|
|
native_cloud_bucket_name=native_cloud_bucket_name,
|
|
native_cloud_bucket_provider=native_cloud_bucket_provider,
|
|
native_cloud_bucket_mount_path=native_cloud_bucket_mount_path,
|
|
native_cloud_bucket_endpoint_url=native_cloud_bucket_endpoint_url,
|
|
native_cloud_bucket_key_prefix=native_cloud_bucket_key_prefix,
|
|
native_cloud_bucket_secret_name=native_cloud_bucket_secret_name,
|
|
)
|
|
|
|
await _verify_stop_resume(
|
|
manifest=manifest,
|
|
app_name=app_name,
|
|
workspace_persistence=workspace_persistence,
|
|
sandbox_create_timeout_s=sandbox_create_timeout_s,
|
|
)
|
|
|
|
agent = SandboxAgent(
|
|
name="Modal 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"),
|
|
)
|
|
|
|
run_config = RunConfig(
|
|
sandbox=SandboxRunConfig(
|
|
client=ModalSandboxClient(),
|
|
options=ModalSandboxClientOptions(
|
|
app_name=app_name,
|
|
workspace_persistence=workspace_persistence,
|
|
sandbox_create_timeout_s=sandbox_create_timeout_s,
|
|
),
|
|
),
|
|
workflow_name="Modal 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__":
|
|
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(
|
|
"--app-name",
|
|
default="openai-agents-python-sandbox-example",
|
|
help="Modal app name to create or reuse for the sandbox.",
|
|
)
|
|
parser.add_argument(
|
|
"--workspace-persistence",
|
|
default="tar",
|
|
choices=["tar", "snapshot_filesystem", "snapshot_directory"],
|
|
help="Workspace persistence mode for the Modal sandbox.",
|
|
)
|
|
parser.add_argument(
|
|
"--sandbox-create-timeout-s",
|
|
type=float,
|
|
default=None,
|
|
help="Optional timeout for creating the Modal sandbox.",
|
|
)
|
|
parser.add_argument(
|
|
"--native-cloud-bucket-name",
|
|
default=None,
|
|
help="Optional cloud bucket name to mount with ModalCloudBucketMountStrategy.",
|
|
)
|
|
parser.add_argument(
|
|
"--native-cloud-bucket-provider",
|
|
default="s3",
|
|
choices=["s3", "gcs-hmac"],
|
|
help="Provider type for --native-cloud-bucket-name.",
|
|
)
|
|
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.",
|
|
)
|
|
parser.add_argument(
|
|
"--native-cloud-bucket-key-prefix",
|
|
default=None,
|
|
help="Optional key prefix for --native-cloud-bucket-name.",
|
|
)
|
|
parser.add_argument(
|
|
"--native-cloud-bucket-secret-name",
|
|
default=None,
|
|
help=(
|
|
"Optional named Modal Secret to use for --native-cloud-bucket-name instead of "
|
|
"reading raw credentials from environment variables."
|
|
),
|
|
)
|
|
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,
|
|
app_name=args.app_name,
|
|
workspace_persistence=args.workspace_persistence,
|
|
sandbox_create_timeout_s=args.sandbox_create_timeout_s,
|
|
native_cloud_bucket_name=args.native_cloud_bucket_name,
|
|
native_cloud_bucket_provider=args.native_cloud_bucket_provider,
|
|
native_cloud_bucket_mount_path=args.native_cloud_bucket_mount_path,
|
|
native_cloud_bucket_endpoint_url=args.native_cloud_bucket_endpoint_url,
|
|
native_cloud_bucket_key_prefix=args.native_cloud_bucket_key_prefix,
|
|
native_cloud_bucket_secret_name=args.native_cloud_bucket_secret_name,
|
|
stream=args.stream,
|
|
)
|
|
)
|