Skip to content

Commit 08fb58e

Browse files
committed
feat: add Sprites sandbox provider
Adds Sprites (https://sprites.dev) as a first-class sandbox backend, peer to E2B/Modal/Vercel/etc. The provider implements the full BaseSandboxClient and BaseSandboxSession contracts: lifecycle (ephemeral + named-attach), exec via sprites-py async ControlConnection, filesystem read/write, PTY parity with E2B, exposed-port resolution against the sprite's single public service URL, and tar-based workspace persistence. Includes a SpritesPlatformContext capability that injects /.sprite/llm.txt into the agent's instructions so it learns sprite-env services/checkpoints/ URL routing from the platform itself. Adds the [sprites] optional extra (sprites-py>=0.0.1rc37,<0.2), a mypy override, parametrized entries in tests/sandbox/test_compatibility_guards.py for all six guards, a unit suite at tests/extensions/test_sandbox_sprites.py, a sprites_runner.py example, the docs/sandbox/clients.md provider table entries, and the docs/ref/extensions/sandbox/sprites/sandbox.md ref page.
1 parent 8025ed0 commit 08fb58e

12 files changed

Lines changed: 2578 additions & 3 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# `Sandbox`
2+
3+
::: agents.extensions.sandbox.sprites.sandbox

docs/sandbox/clients.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ For provider-specific setup notes and links for the checked-in extension example
9696
| `E2BSandboxClient` | `openai-agents[e2b]` | [E2B runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) |
9797
| `ModalSandboxClient` | `openai-agents[modal]` | [Modal runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) |
9898
| `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) |
99+
| `SpritesSandboxClient` | `openai-agents[sprites]` | [Sprites runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/sprites_runner.py) |
99100
| `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) |
100101

101102
</div>
@@ -113,6 +114,7 @@ Hosted sandbox clients expose provider-specific mount strategies. Choose the bac
113114
| `DaytonaSandboxClient` | Supports rclone-backed cloud storage mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
114115
| `E2BSandboxClient` | Supports rclone-backed cloud storage mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
115116
| `RunloopSandboxClient` | Supports rclone-backed cloud storage mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `BoxMount`. |
117+
| `SpritesSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. Sprites exposes at most one external HTTP port per sprite (declared as a service in the sprite image); other ports must be reverse-proxied inside the VM. |
116118
| `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. |
117119

118120
</div>
@@ -130,6 +132,7 @@ The table below summarizes which remote storage entries each backend can mount d
130132
| `DaytonaSandboxClient` |||||| - |
131133
| `E2BSandboxClient` |||||| - |
132134
| `RunloopSandboxClient` |||||| - |
135+
| `SpritesSandboxClient` | - | - | - | - | - | - |
133136
| `VercelSandboxClient` | - | - | - | - | - | - |
134137

135138
</div>

examples/sandbox/extensions/README.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ They intentionally keep the flow simple:
77

88
1. Build a tiny manifest in memory.
99
2. Create a `SandboxAgent` that inspects that workspace through one shell tool.
10-
3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Blaxel, or Vercel.
10+
3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Blaxel, Sprites, or Vercel.
1111

1212
All of these examples require `OPENAI_API_KEY`, because they call the model through the normal
1313
`Runner` path. Each cloud backend also needs its own provider credentials.
@@ -328,6 +328,46 @@ the default home and working directory become `/root`, so the example also uses
328328
`/root` as its manifest workspace root. If you configure root launch in your
329329
own code, either rely on that root-mode default or explicitly choose a
330330
`manifest.root` under `/root`.
331+
## Sprites
332+
333+
### Setup
334+
335+
Install the repo extra:
336+
337+
```bash
338+
uv sync --extra sprites
339+
```
340+
341+
Create a Sprites organization and API token at [sprites.dev](https://sprites.dev/),
342+
and export the required environment variables:
343+
344+
```bash
345+
export OPENAI_API_KEY=...
346+
export SPRITES_API_TOKEN=...
347+
# Optional, defaults to https://api.sprites.dev:
348+
# export SPRITES_API_URL=https://api.sprites.dev
349+
```
350+
351+
### Run
352+
353+
```bash
354+
uv run python examples/sandbox/extensions/sprites_runner.py --stream
355+
```
356+
357+
Useful flags:
358+
359+
- `--sprite-name <name>` — attach to an existing sprite instead of creating an
360+
ephemeral one. The example skips delete-on-exit when this is set.
361+
- `--skip-snapshot-check` — skip the tar workspace persistence verification.
362+
- `--question "..."` — override the default prompt.
363+
364+
The Sprites client resolves the API token from `SPRITES_API_TOKEN` (override via
365+
`SpritesSandboxClient(token=...)`) and supports exec, filesystem read/write,
366+
PTY-mode interactive exec, and tar-based workspace snapshots. Sprites exposes
367+
at most one external HTTP port per sprite — declare it as a service with
368+
`--http-port` in the sprite image, then reference it via
369+
`SpritesSandboxClientOptions(exposed_ports=(<port>,))`.
370+
331371
## Blaxel
332372

333373
### Setup
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
Minimal Sprites-backed sandbox example for manual validation.
3+
4+
This example creates a small in-memory workspace, lets the agent inspect it
5+
through one shell tool, and prints a short answer. By default an ephemeral
6+
sprite is created and deleted at the end; pass ``--sprite-name <name>`` to
7+
attach to an existing sprite instead.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import asyncio
14+
import io
15+
import os
16+
import sys
17+
import tempfile
18+
from pathlib import Path
19+
from typing import cast
20+
21+
from openai.types.responses import ResponseTextDeltaEvent
22+
23+
from agents import ModelSettings, Runner
24+
from agents.run import RunConfig
25+
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
26+
from agents.sandbox.session import BaseSandboxSession
27+
28+
if __package__ is None or __package__ == "":
29+
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
30+
31+
from examples.sandbox.misc.example_support import text_manifest # noqa: E402
32+
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability # noqa: E402
33+
34+
try:
35+
from agents.extensions.sandbox import (
36+
SpritesSandboxClient,
37+
SpritesSandboxClientOptions,
38+
)
39+
except Exception as exc: # pragma: no cover - import path depends on optional extras
40+
raise SystemExit(
41+
"Sprites sandbox examples require the optional repo extra.\n"
42+
"Install it with: uv sync --extra sprites"
43+
) from exc
44+
45+
46+
DEFAULT_QUESTION = "Summarize this sandbox workspace in 2 sentences."
47+
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
48+
SNAPSHOT_CHECK_CONTENT = "sprites snapshot round-trip ok\n"
49+
50+
51+
def _build_manifest() -> Manifest:
52+
return text_manifest(
53+
{
54+
"README.md": (
55+
"# Sprites Demo Workspace\n\n"
56+
"This workspace exists to validate the Sprites sandbox backend manually.\n"
57+
),
58+
"handoff.md": (
59+
"# Handoff\n\n"
60+
"- Customer: Northwind Traders.\n"
61+
"- Goal: validate Sprites sandbox exec and persistence flows.\n"
62+
"- Current status: v1 backend slice (exec + fs + PTY) is wired and under test.\n"
63+
),
64+
"todo.md": (
65+
"# Todo\n\n"
66+
"1. Inspect the workspace files.\n"
67+
"2. Summarize the current status in two sentences.\n"
68+
),
69+
}
70+
)
71+
72+
73+
def _require_env(name: str) -> None:
74+
if os.environ.get(name):
75+
return
76+
raise SystemExit(f"{name} must be set before running this example.")
77+
78+
79+
async def _read_text(session: BaseSandboxSession, path: Path) -> str:
80+
data = await session.read(path)
81+
text = cast(str | bytes, data.read())
82+
if isinstance(text, bytes):
83+
return text.decode("utf-8")
84+
return text
85+
86+
87+
async def _verify_stop_resume(*, sprite_name: str | None) -> None:
88+
"""Round-trip a workspace through tar persistence and reattach.
89+
90+
With ``sprite_name=None`` an ephemeral sprite is created, persisted, and
91+
then resumed against itself. With a named sprite the same flow runs
92+
against the existing sprite (no create/delete on the API).
93+
"""
94+
95+
client = SpritesSandboxClient()
96+
options = SpritesSandboxClientOptions(sprite_name=sprite_name)
97+
98+
with tempfile.TemporaryDirectory(prefix="sprites-snapshot-example-") as snapshot_dir:
99+
sandbox = await client.create(
100+
manifest=_build_manifest(),
101+
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
102+
options=options,
103+
)
104+
105+
try:
106+
await sandbox.start()
107+
await sandbox.write(
108+
SNAPSHOT_CHECK_PATH,
109+
io.BytesIO(SNAPSHOT_CHECK_CONTENT.encode("utf-8")),
110+
)
111+
await sandbox.stop()
112+
finally:
113+
await sandbox.shutdown()
114+
115+
resumed = await client.resume(sandbox.state)
116+
try:
117+
await resumed.start()
118+
restored = await _read_text(resumed, SNAPSHOT_CHECK_PATH)
119+
if restored != SNAPSHOT_CHECK_CONTENT:
120+
raise RuntimeError(
121+
f"Snapshot resume verification failed: expected "
122+
f"{SNAPSHOT_CHECK_CONTENT!r}, got {restored!r}"
123+
)
124+
finally:
125+
await resumed.aclose()
126+
if sprite_name is None:
127+
# Ephemeral sandbox should clean up the sprite created by ``resume``.
128+
await client.delete(resumed)
129+
130+
print("snapshot round-trip ok")
131+
132+
133+
async def main(
134+
*,
135+
model: str,
136+
question: str,
137+
sprite_name: str | None,
138+
skip_snapshot_check: bool,
139+
stream: bool,
140+
) -> None:
141+
_require_env("OPENAI_API_KEY")
142+
_require_env("SPRITES_API_TOKEN")
143+
144+
if not skip_snapshot_check:
145+
await _verify_stop_resume(sprite_name=sprite_name)
146+
147+
manifest = _build_manifest()
148+
agent = SandboxAgent(
149+
name="Sprites Sandbox Assistant",
150+
model=model,
151+
instructions=(
152+
"Answer questions about the sandbox workspace. Inspect the files before answering "
153+
"and keep the response concise. Cite the file names you inspected."
154+
),
155+
default_manifest=manifest,
156+
capabilities=[WorkspaceShellCapability()],
157+
model_settings=ModelSettings(tool_choice="required"),
158+
)
159+
160+
client = SpritesSandboxClient()
161+
sandbox = await client.create(
162+
manifest=manifest,
163+
options=SpritesSandboxClientOptions(sprite_name=sprite_name),
164+
)
165+
166+
run_config = RunConfig(
167+
sandbox=SandboxRunConfig(session=sandbox),
168+
tracing_disabled=True,
169+
workflow_name="Sprites sandbox example",
170+
)
171+
172+
try:
173+
async with sandbox:
174+
if not stream:
175+
result = await Runner.run(agent, question, run_config=run_config)
176+
print(result.final_output)
177+
return
178+
179+
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
180+
saw_text_delta = False
181+
async for event in stream_result.stream_events():
182+
if event.type == "raw_response_event" and isinstance(
183+
event.data, ResponseTextDeltaEvent
184+
):
185+
if not saw_text_delta:
186+
print("assistant> ", end="", flush=True)
187+
saw_text_delta = True
188+
print(event.data.delta, end="", flush=True)
189+
190+
if saw_text_delta:
191+
print()
192+
finally:
193+
await client.delete(sandbox)
194+
195+
196+
if __name__ == "__main__":
197+
parser = argparse.ArgumentParser()
198+
parser.add_argument("--model", default="gpt-5.5", help="Model name to use.")
199+
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
200+
parser.add_argument(
201+
"--sprite-name",
202+
default=None,
203+
help=(
204+
"Existing sprite to attach to. When omitted, an ephemeral sprite is "
205+
"created and deleted automatically."
206+
),
207+
)
208+
parser.add_argument(
209+
"--skip-snapshot-check",
210+
action="store_true",
211+
default=False,
212+
help="Skip the tar workspace persistence verification before the agent run.",
213+
)
214+
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
215+
args = parser.parse_args()
216+
217+
asyncio.run(
218+
main(
219+
model=args.model,
220+
question=args.question,
221+
sprite_name=args.sprite_name,
222+
skip_snapshot_check=args.skip_snapshot_check,
223+
stream=args.stream,
224+
)
225+
)

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ cloudflare = ["aiohttp>=3.12,<4"]
5252
e2b = ["e2b==2.20.0", "e2b-code-interpreter==2.4.1"]
5353
modal = ["modal==1.3.5"]
5454
runloop = ["runloop_api_client>=1.16.0,<2.0.0"]
55+
sprites = ["sprites-py>=0.0.1rc37,<0.2"]
5556
vercel = ["vercel>=0.5.6,<0.6"]
5657
s3 = ["boto3>=1.34"]
5758
temporal = [
@@ -164,6 +165,10 @@ ignore_missing_imports = true
164165
module = ["vercel", "vercel.*"]
165166
ignore_missing_imports = true
166167

168+
[[tool.mypy.overrides]]
169+
module = ["sprites", "sprites.*"]
170+
ignore_missing_imports = true
171+
167172
[tool.coverage.run]
168173
source = ["src/agents"]
169174
omit = [

src/agents/extensions/sandbox/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,23 @@
109109
except Exception: # pragma: no cover
110110
_HAS_VERCEL = False
111111

112+
try:
113+
from .sprites import (
114+
DEFAULT_SPRITES_API_URL as DEFAULT_SPRITES_API_URL,
115+
DEFAULT_SPRITES_CONTEXT_PATH as DEFAULT_SPRITES_CONTEXT_PATH,
116+
DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S as DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S,
117+
DEFAULT_SPRITES_WORKSPACE_ROOT as DEFAULT_SPRITES_WORKSPACE_ROOT,
118+
SpritesPlatformContext as SpritesPlatformContext,
119+
SpritesSandboxClient as SpritesSandboxClient,
120+
SpritesSandboxClientOptions as SpritesSandboxClientOptions,
121+
SpritesSandboxSession as SpritesSandboxSession,
122+
SpritesSandboxSessionState as SpritesSandboxSessionState,
123+
)
124+
125+
_HAS_SPRITES = True
126+
except Exception: # pragma: no cover
127+
_HAS_SPRITES = False
128+
112129
__all__: list[str] = []
113130

114131
if _HAS_E2B:
@@ -207,3 +224,18 @@
207224
"RunloopUserParameters",
208225
]
209226
)
227+
228+
if _HAS_SPRITES:
229+
__all__.extend(
230+
[
231+
"DEFAULT_SPRITES_API_URL",
232+
"DEFAULT_SPRITES_CONTEXT_PATH",
233+
"DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S",
234+
"DEFAULT_SPRITES_WORKSPACE_ROOT",
235+
"SpritesPlatformContext",
236+
"SpritesSandboxClient",
237+
"SpritesSandboxClientOptions",
238+
"SpritesSandboxSession",
239+
"SpritesSandboxSessionState",
240+
]
241+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
from .capabilities import (
4+
DEFAULT_SPRITES_CONTEXT_PATH,
5+
SpritesPlatformContext,
6+
)
7+
from .sandbox import (
8+
DEFAULT_SPRITES_API_URL,
9+
DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S,
10+
DEFAULT_SPRITES_WORKSPACE_ROOT,
11+
SpritesSandboxClient,
12+
SpritesSandboxClientOptions,
13+
SpritesSandboxSession,
14+
SpritesSandboxSessionState,
15+
)
16+
17+
__all__ = [
18+
"DEFAULT_SPRITES_API_URL",
19+
"DEFAULT_SPRITES_CONTEXT_PATH",
20+
"DEFAULT_SPRITES_WAIT_FOR_RUNNING_TIMEOUT_S",
21+
"DEFAULT_SPRITES_WORKSPACE_ROOT",
22+
"SpritesPlatformContext",
23+
"SpritesSandboxClient",
24+
"SpritesSandboxClientOptions",
25+
"SpritesSandboxSession",
26+
"SpritesSandboxSessionState",
27+
]

0 commit comments

Comments
 (0)