Skip to content

Commit 45b29d6

Browse files
committed
feat: add OpenShell sandbox provider extension
Add NVIDIA OpenShell as a sandbox provider, wrapping the `openshell` Python SDK (sync gRPC client) via run_in_executor. Implements the standard BaseSandboxClient/BaseSandboxSession contracts with gateway discovery, tar-based workspace persistence, and file I/O via exec. Closes #3468
1 parent fedc809 commit 45b29d6

8 files changed

Lines changed: 1611 additions & 0 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.openshell.sandbox

docs/sandbox/clients.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ For provider-specific setup notes and links for the checked-in extension example
9595
| `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) |
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) |
98+
| `OpenShellSandboxClient` | `openai-agents[openshell]` | [OpenShell runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/openshell_runner.py) |
9899
| `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/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

@@ -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+
| `OpenShellSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. |
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+
| `OpenShellSandboxClient` | - | - | - | - | - | - |
133136
| `VercelSandboxClient` | - | - | - | - | - | - |
134137

135138
</div>
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
"""
2+
OpenShell sandbox integration example.
3+
4+
This script exercises the OpenShell sandbox extension at two levels:
5+
6+
1. **Session-level** (no LLM needed): Creates a sandbox, writes files, reads them
7+
back, runs commands, and verifies workspace persistence. This validates the
8+
extension works end-to-end with a real OpenShell gateway.
9+
10+
2. **Agent-level** (requires OPENAI_API_KEY): Runs a SandboxAgent with a shell
11+
capability inside the OpenShell sandbox.
12+
13+
Prerequisites:
14+
- An OpenShell gateway running (local, remote, or cloud).
15+
- ``openshell`` Python package installed: ``uv sync --extra openshell``
16+
- For agent mode: ``OPENAI_API_KEY`` environment variable set.
17+
18+
Quick start:
19+
# Session-level only (no LLM):
20+
uv run python examples/sandbox/extensions/openshell_runner.py --session-only
21+
22+
# Full agent run:
23+
uv run python examples/sandbox/extensions/openshell_runner.py
24+
25+
# With a specific cluster:
26+
uv run python examples/sandbox/extensions/openshell_runner.py --cluster my-gateway
27+
28+
# With a custom image:
29+
uv run python examples/sandbox/extensions/openshell_runner.py --image ubuntu:24.04
30+
"""
31+
32+
from __future__ import annotations
33+
34+
import argparse
35+
import asyncio
36+
import io
37+
import os
38+
import sys
39+
from pathlib import Path
40+
41+
try:
42+
from agents.extensions.sandbox import (
43+
OpenShellSandboxClient,
44+
OpenShellSandboxClientOptions,
45+
)
46+
except Exception as exc:
47+
raise SystemExit(
48+
"OpenShell sandbox examples require the optional openshell extra.\n"
49+
"Install it with: uv sync --extra openshell"
50+
) from exc
51+
52+
53+
async def session_level_test(
54+
*,
55+
cluster: str | None,
56+
endpoint: str | None,
57+
image: str | None,
58+
gpu: bool,
59+
) -> None:
60+
"""Exercise the sandbox extension directly without an LLM."""
61+
62+
from agents.sandbox import Manifest
63+
from agents.sandbox.entries import File
64+
65+
print("=== OpenShell Session-Level Test ===\n")
66+
67+
# Build a manifest with test files.
68+
# OpenShell sandboxes default to /sandbox as the working directory.
69+
manifest = Manifest(
70+
root="/sandbox",
71+
entries={
72+
"hello.txt": File(content=b"Hello from OpenShell sandbox!\n"),
73+
"data/numbers.csv": File(content=b"a,b,c\n1,2,3\n4,5,6\n"),
74+
},
75+
)
76+
77+
client = OpenShellSandboxClient()
78+
options = OpenShellSandboxClientOptions(
79+
cluster=cluster,
80+
endpoint=endpoint,
81+
image=image,
82+
gpu=gpu,
83+
)
84+
85+
print("1. Creating sandbox...")
86+
session = await client.create(manifest=manifest, options=options)
87+
88+
try:
89+
print("2. Starting session (materializing workspace)...")
90+
await session.start()
91+
92+
print("3. Running 'ls -la' in workspace...")
93+
result = await session.exec("ls", "-la", shell=False)
94+
print(f" exit_code={result.exit_code}")
95+
print(f" stdout:\n{result.stdout.decode()}")
96+
97+
print("4. Reading hello.txt...")
98+
content = await session.read(Path("hello.txt"))
99+
text = content.read()
100+
if isinstance(text, bytes):
101+
text = text.decode("utf-8")
102+
print(f" content: {text.strip()!r}")
103+
assert "Hello from OpenShell sandbox!" in text, "Read verification failed."
104+
105+
print("5. Writing a new file...")
106+
await session.write(
107+
Path("output.txt"),
108+
io.BytesIO(b"Written by the OpenAI Agents SDK via OpenShell.\n"),
109+
)
110+
111+
print("6. Verifying the written file...")
112+
result = await session.exec("cat", "output.txt", shell=False)
113+
assert result.exit_code == 0, f"cat failed: {result.stderr.decode()}"
114+
print(f" content: {result.stdout.decode().strip()!r}")
115+
116+
print("7. Running a multi-step shell command...")
117+
result = await session.exec("wc -l data/numbers.csv && echo 'done'")
118+
print(f" output: {result.stdout.decode().strip()}")
119+
120+
print("8. Checking sandbox is running...")
121+
is_running = await session.running()
122+
print(f" running: {is_running}")
123+
assert is_running, "Sandbox should be running."
124+
125+
print("9. Persisting workspace (tar snapshot)...")
126+
snapshot = await session.persist_workspace()
127+
snapshot_bytes = snapshot.read()
128+
print(f" snapshot size: {len(snapshot_bytes)} bytes")
129+
assert len(snapshot_bytes) > 0, "Snapshot should not be empty."
130+
131+
print("\nAll session-level checks passed.")
132+
133+
finally:
134+
print("\n10. Shutting down sandbox...")
135+
await session.aclose()
136+
print(" Done.")
137+
138+
139+
async def agent_level_test(
140+
*,
141+
model: str,
142+
cluster: str | None,
143+
endpoint: str | None,
144+
image: str | None,
145+
gpu: bool,
146+
question: str,
147+
stream: bool,
148+
) -> None:
149+
"""Run a SandboxAgent backed by OpenShell."""
150+
151+
from openai.types.responses import ResponseTextDeltaEvent
152+
153+
from agents import ModelSettings, Runner
154+
from agents.run import RunConfig
155+
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
156+
from agents.sandbox.entries import File
157+
158+
if __package__ is None or __package__ == "":
159+
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
160+
161+
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
162+
163+
print("\n=== OpenShell Agent-Level Test ===\n")
164+
165+
manifest = Manifest(
166+
root="/sandbox",
167+
entries={
168+
"README.md": File(
169+
content=(
170+
b"# Project Status\n\nThis workspace contains a sample project status report.\n"
171+
),
172+
),
173+
"status.md": File(
174+
content=(
175+
b"# Sprint 42 Status\n\n"
176+
b"- Auth service: on track, shipping Tuesday.\n"
177+
b"- Search reindex: blocked on infra ticket INFRA-1234.\n"
178+
b"- Dashboard v2: 80% complete, needs UX review.\n"
179+
),
180+
),
181+
},
182+
)
183+
184+
agent = SandboxAgent(
185+
name="OpenShell Sandbox Assistant",
186+
model=model,
187+
instructions=(
188+
"Answer questions about the sandbox workspace. Inspect the files before answering "
189+
"and keep the response concise. "
190+
"Do not invent files or statuses that are not present in the workspace. Cite the "
191+
"file names you inspected."
192+
),
193+
default_manifest=manifest,
194+
capabilities=[WorkspaceShellCapability()],
195+
model_settings=ModelSettings(tool_choice="required"),
196+
)
197+
198+
run_config = RunConfig(
199+
sandbox=SandboxRunConfig(
200+
client=OpenShellSandboxClient(),
201+
options=OpenShellSandboxClientOptions(
202+
cluster=cluster,
203+
endpoint=endpoint,
204+
image=image,
205+
gpu=gpu,
206+
),
207+
),
208+
workflow_name="OpenShell sandbox example",
209+
)
210+
211+
if not stream:
212+
result = await Runner.run(agent, question, run_config=run_config)
213+
print(f"assistant> {result.final_output}")
214+
return
215+
216+
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
217+
saw_text_delta = False
218+
async for event in stream_result.stream_events():
219+
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
220+
if not saw_text_delta:
221+
print("assistant> ", end="", flush=True)
222+
saw_text_delta = True
223+
print(event.data.delta, end="", flush=True)
224+
if saw_text_delta:
225+
print()
226+
227+
228+
async def main(
229+
*,
230+
model: str,
231+
cluster: str | None,
232+
endpoint: str | None,
233+
image: str | None,
234+
gpu: bool,
235+
question: str,
236+
stream: bool,
237+
session_only: bool,
238+
) -> None:
239+
# Session-level test always runs (no LLM needed).
240+
await session_level_test(
241+
cluster=cluster,
242+
endpoint=endpoint,
243+
image=image,
244+
gpu=gpu,
245+
)
246+
247+
if session_only:
248+
return
249+
250+
# Agent-level test requires OPENAI_API_KEY.
251+
if not os.environ.get("OPENAI_API_KEY"):
252+
print("\nSkipping agent-level test (OPENAI_API_KEY not set).")
253+
print("Set OPENAI_API_KEY and remove --session-only to run the full test.")
254+
return
255+
256+
await agent_level_test(
257+
model=model,
258+
cluster=cluster,
259+
endpoint=endpoint,
260+
image=image,
261+
gpu=gpu,
262+
question=question,
263+
stream=stream,
264+
)
265+
266+
267+
if __name__ == "__main__":
268+
parser = argparse.ArgumentParser(
269+
description="OpenShell sandbox integration example for the OpenAI Agents SDK."
270+
)
271+
parser.add_argument("--model", default="gpt-4.1-mini", help="Model name to use.")
272+
parser.add_argument(
273+
"--question",
274+
default="Summarize the project status from the workspace files.",
275+
help="Prompt to send to the agent.",
276+
)
277+
parser.add_argument("--cluster", default=None, help="OpenShell gateway cluster name.")
278+
parser.add_argument("--endpoint", default=None, help="Explicit gateway endpoint (host:port).")
279+
parser.add_argument("--image", default=None, help="Container image for the sandbox.")
280+
parser.add_argument("--gpu", action="store_true", default=False, help="Request GPU.")
281+
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
282+
parser.add_argument(
283+
"--session-only",
284+
action="store_true",
285+
default=False,
286+
help="Run session-level test only (no LLM needed).",
287+
)
288+
args = parser.parse_args()
289+
290+
asyncio.run(
291+
main(
292+
model=args.model,
293+
cluster=args.cluster,
294+
endpoint=args.endpoint,
295+
image=args.image,
296+
gpu=args.gpu,
297+
question=args.question,
298+
stream=args.stream,
299+
session_only=args.session_only,
300+
)
301+
)

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ 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"]
5555
vercel = ["vercel>=0.5.6,<0.6"]
56+
openshell = ["openshell>=0.0.0a0"]
5657
s3 = ["boto3>=1.34"]
5758
temporal = [
5859
"temporalio==1.26.0",
@@ -164,6 +165,10 @@ ignore_missing_imports = true
164165
module = ["vercel", "vercel.*"]
165166
ignore_missing_imports = true
166167

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

src/agents/extensions/sandbox/__init__.py

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

112+
try:
113+
from .openshell import (
114+
OpenShellSandboxClient as OpenShellSandboxClient,
115+
OpenShellSandboxClientOptions as OpenShellSandboxClientOptions,
116+
OpenShellSandboxSession as OpenShellSandboxSession,
117+
OpenShellSandboxSessionState as OpenShellSandboxSessionState,
118+
)
119+
120+
_HAS_OPENSHELL = True
121+
except Exception: # pragma: no cover
122+
_HAS_OPENSHELL = False
123+
112124
__all__: list[str] = []
113125

114126
if _HAS_E2B:
@@ -207,3 +219,13 @@
207219
"RunloopUserParameters",
208220
]
209221
)
222+
223+
if _HAS_OPENSHELL:
224+
__all__.extend(
225+
[
226+
"OpenShellSandboxClient",
227+
"OpenShellSandboxClientOptions",
228+
"OpenShellSandboxSession",
229+
"OpenShellSandboxSessionState",
230+
]
231+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
from .sandbox import (
4+
OpenShellSandboxClient as OpenShellSandboxClient,
5+
OpenShellSandboxClientOptions as OpenShellSandboxClientOptions,
6+
OpenShellSandboxSession as OpenShellSandboxSession,
7+
OpenShellSandboxSessionState as OpenShellSandboxSessionState,
8+
)
9+
10+
__all__ = [
11+
"OpenShellSandboxClient",
12+
"OpenShellSandboxClientOptions",
13+
"OpenShellSandboxSession",
14+
"OpenShellSandboxSessionState",
15+
]

0 commit comments

Comments
 (0)