Sandbox Agents (#2889)

### Sandbox Agents

This release adds **Sandbox Agents**, a beta SDK surface for running
agents with a persistent, isolated workspace. Sandbox agents keep the
normal `Agent` and `Runner` flow, but add workspace manifests,
sandbox-native capabilities, sandbox clients, snapshots, and resume
support so agents can work over real files, run commands, edit
repositories, generate artifacts, and continue work across runs.

Key pieces:

- `SandboxAgent`: an `Agent` with sandbox defaults such as
`default_manifest`, sandbox instructions, capabilities, and `run_as`.
- `Manifest`: a fresh-workspace contract for files, directories, local
files, local directories, Git repos, environment, users, groups, and
mounts.
- `SandboxRunConfig`: per-run sandbox wiring for client creation, live
session injection, serialized session resume, manifest overrides,
snapshots, and materialization concurrency limits.
- Built-in capabilities for shell access, filesystem editing and image
inspection, skills, memory, and compaction.
- Workspace snapshots and serialized sandbox session state for
reconnecting to existing work or seeding a fresh sandbox from saved
contents.

### Sandbox clients and hosted providers

Sandbox agents now support local, containerized, and hosted execution
backends:

- `UnixLocalSandboxClient` for fast local development.
- `DockerSandboxClient` for container isolation and image parity.
- Hosted sandbox clients for Blaxel, Cloudflare, Daytona, E2B, Modal,
Runloop, and Vercel through optional extras.

The release also adds provider-specific examples and mount strategies
for common storage backends, including S3, Cloudflare R2, Google Cloud
Storage, Azure Blob Storage, and S3 Files where supported by the
selected backend.

### Sandbox memory

Adds a sandbox memory capability that lets future sandbox-agent runs
learn from prior runs. Memory stores extracted lessons in the sandbox
workspace, injects a concise summary into later runs, and uses
progressive disclosure so agents can search deeper rollout summaries
only when useful.

Memory supports:

- Read-only or generate-only modes.
- Live updates when the agent discovers stale memory.
- Multi-turn grouping through `conversation_id`, SDK `Session`,
`RunConfig.group_id`, or generated run IDs.
- Separate memory layouts for isolating memory across agents or
workflows.
- S3-backed examples for persisted memory across runs.

### Workspace mounts, snapshots, and resume

This release adds a full workspace entry and mount model for sandbox
sessions:

- Local files and directories.
- Synthetic files and directories.
- Git repository entries.
- Remote storage mounts for S3, R2, GCS, Azure Blob Storage, and S3
Files.
- Provider-specific mount strategies across Docker, Modal, Cloudflare,
Blaxel, Daytona, E2B, and Runloop.
- Portable snapshots with path normalization, symlink preservation,
mount-safe snapshotting, and remote snapshot support.
- Resume paths through runner-managed `RunState`, explicit
`SandboxSessionState`, or saved snapshots.

### Examples and tutorials

Adds a large `examples/sandbox/` suite covering:

- Local Unix and Docker sandbox runners.
- Docker mount smoke tests for S3, GCS, Azure Blob Storage, and S3
Files.
- Sandbox coding tasks with skills.
- Sandbox agents as tools and handoff patterns.
- Memory examples, including multi-agent/multi-turn memory and S3-backed
memory.
- Tax-prep and healthcare-support workflows.
- Dataroom QA and metric extraction tutorials.
- Repository code review tutorial.
- Vision website clone tutorial.
- Provider examples for Blaxel, Cloudflare, Daytona, E2B, Modal,
Runloop, Temporal, and Vercel.

### Runtime, tracing, and model plumbing

The release includes the runtime plumbing needed to make sandbox agents
work naturally inside the existing SDK:

- Runner-managed sandbox preparation, capability binding, session
lifecycle, state serialization, and resume behavior.
- Sandbox-aware `RunState` serialization.
- Unified sandbox tracing with SDK spans.
- Token usage on tracing spans.
- Runner-managed prompt cache key defaults.
- OpenAI agent registration and harness ID configuration.
- Safer redaction of sensitive MCP tool outputs when sensitive tracing
is disabled.
- Additional OpenAI client/model utilities and Chat Completions
coverage.


## Documentation & Other Changes

- docs: add Asqav to external tracing processors list.
- docs: update translated document pages.

Co-authored-by: Abdulrahman Alfozan <alfozan@openai.com>
Co-authored-by: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com>
Co-authored-by: Andi Liu <andi@openai.com>
Co-authored-by: Aron <263346377+aron-cf@users.noreply.github.com>
Co-authored-by: ashwinnathan-openai <ashwinnathan@openai.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: cploujoux <cploujoux@blaxel.ai>
Co-authored-by: elainegan-openai <168589666+elainegan-openai@users.noreply.github.com>
Co-authored-by: Elias Freider <freider@users.noreply.github.com>
Co-authored-by: Erik Dunteman <erik@erikds-macbook-air.local>
Co-authored-by: Jason Liu <jasonliu@openai.com>
Co-authored-by: Jason Steving <32336750+jasonsteving99@users.noreply.github.com>
Co-authored-by: Kazuhiro Sera <seratch@openai.com>
Co-authored-by: Lovre Pešut <lovre.pesut@gmail.com>
Co-authored-by: Lucas Wang <lucas_wang@lucas-futures.com>
Co-authored-by: Matt Brockman <matt.brockman@e2b.dev>
Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com>
Co-authored-by: Naresh <ghostwriternr@gmail.com>
Co-authored-by: nicholasclark-openai <nicholasclark@openai.com>
Co-authored-by: qiyaoq-oai <qiyaoq@openai.com>
Co-authored-by: Scott Trinh <scott@scotttrinh.com>
Co-authored-by: tode-rl <tony@runloop.ai>
Co-authored-by: Wendy Jiao <wendyjiao@openai.com>
This commit is contained in:
Steve Coffey
2026-04-15 10:00:40 -07:00
committed by GitHub
parent 86739b1a0f
commit 2d665c9a67
459 changed files with 95138 additions and 1517 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ A clear and concise description of what the bug is.
### Debug information
- Agents SDK version: (e.g. `v0.0.3`)
- Python version (e.g. Python 3.10)
- Python version (e.g. Python 3.14)
### Repro steps
+1 -1
View File
@@ -17,7 +17,7 @@ A clear and concise description of what the question or bug is.
### Debug information
- Agents SDK version: (e.g. `v0.0.3`)
- Python version (e.g. Python 3.10)
- Python version (e.g. Python 3.14)
### Repro steps
Ideally provide a minimal python script that can be run to reproduce the issue.
+1
View File
@@ -91,6 +91,7 @@ The OpenAI Agents Python repository provides the Python Agents SDK, examples, an
- `src/agents/run_state.py` (RunState serialization/deserialization)
- `src/agents/run_internal/session_persistence.py` (session save/rewind)
- If the serialized RunState shape changes, update `CURRENT_SCHEMA_VERSION` in `src/agents/run_state.py` and the related serialization/deserialization logic. Keep released schema versions readable, and feel free to renumber or squash unreleased schema versions before release when those intermediate snapshots are intentionally unsupported.
- When bumping `CURRENT_SCHEMA_VERSION`, also add or update the matching entry in `SCHEMA_VERSION_SUMMARIES` in `src/agents/run_state.py` so every supported version keeps a short historical note describing what changed in that schema.
## Operation Guide
-1
View File
@@ -1 +0,0 @@
Read the AGENTS.md file for instructions.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+25 -7
View File
@@ -10,6 +10,7 @@ The OpenAI Agents SDK is a lightweight yet powerful framework for building multi
### Core concepts:
1. [**Agents**](https://openai.github.io/openai-agents-python/agents): LLMs configured with instructions, tools, guardrails, and handoffs
1. [**Sandbox Agents**](https://openai.github.io/openai-agents-python/sandbox_agents): Agents preconfigured to work with a container to perform work over long time horizons.
1. **[Agents as tools](https://openai.github.io/openai-agents-python/tools/#agents-as-tools) / [Handoffs](https://openai.github.io/openai-agents-python/handoffs/)**: Delegating to other agents for specific tasks
1. [**Tools**](https://openai.github.io/openai-agents-python/tools/): Various Tools let agents take actions (functions, MCP, hosted tools)
1. [**Guardrails**](https://openai.github.io/openai-agents-python/guardrails/): Configurable safety checks for input and output validation
@@ -45,19 +46,36 @@ uv add openai-agents
For voice support, install with the optional `voice` group: `uv add 'openai-agents[voice]'`. For Redis session support, install with the optional `redis` group: `uv add 'openai-agents[redis]'`.
## Run your first agent
## Run your first Sandbox Agent
[Sandbox Agents](https://openai.github.io/openai-agents-python/sandbox_agents) are new in version 0.14.0. A sandbox agent is an agent that uses a computer environment to perform real work with a filesystem, in an environment you configure and control. Sandbox agents are useful when the agent needs to inspect files, run commands, apply patches, or carry workspace state across longer tasks.
```python
from agents import Agent, Runner
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.entries import GitRepo
from agents.sandbox.sandboxes import UnixLocalSandboxClient
agent = Agent(name="Assistant", instructions="You are a helpful assistant")
agent = SandboxAgent(
name="Workspace Assistant",
instructions="Inspect the sandbox workspace before answering.",
default_manifest=Manifest(
entries={
"repo": GitRepo(repo="openai/openai-agents-python", ref="main"),
}
),
)
result = Runner.run_sync(agent, "Write a haiku about recursion in programming.")
result = Runner.run_sync(
agent,
"Inspect the repo README and summarize what this project does.",
# Run this agent on the local filesystem
run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())),
)
print(result.final_output)
# Code within the code,
# Functions calling themselves,
# Infinite loop's dance.
# This project provides a Python SDK for building multi-agent workflows.
```
(_If running this, ensure you set the `OPENAI_API_KEY` environment variable_)
+6 -1
View File
@@ -2,7 +2,9 @@
Agents are the core building block in your apps. An agent is a large language model (LLM) configured with instructions, tools, and optional runtime behavior such as handoffs, guardrails, and structured outputs.
Use this page when you want to define or customize a single agent. If you are deciding how multiple agents should collaborate, read [Agent orchestration](multi_agent.md).
Use this page when you want to define or customize a single plain `Agent`. If you are deciding how multiple agents should collaborate, read [Agent orchestration](multi_agent.md). If the agent should run inside an isolated workspace with manifest-defined files and sandbox-native capabilities, read [Sandbox agent concepts](sandbox/guide.md).
The SDK uses the Responses API by default for OpenAI models, but the distinction here is orchestration: `Agent` plus `Runner` lets the SDK manage turns, tools, guardrails, handoffs, and sessions for you. If you want to own that loop yourself, use the Responses API directly instead.
## Choose the next guide
@@ -12,6 +14,7 @@ Use this page as the hub for agent definition. Jump to the adjacent guide that m
| --- | --- |
| Choose a model or provider setup | [Models](models/index.md) |
| Add capabilities to the agent | [Tools](tools.md) |
| Run an agent against a real repo, document bundle, or isolated workspace | [Sandbox agents quickstart](sandbox_agents.md) |
| Decide between manager-style orchestration and handoffs | [Agent orchestration](multi_agent.md) |
| Configure handoff behavior | [Handoffs](handoffs.md) |
| Run turns, stream events, or manage conversation state | [Running agents](running_agents.md) |
@@ -57,6 +60,8 @@ agent = Agent(
)
```
Everything in this section applies to `Agent`. `SandboxAgent` builds on the same ideas, then adds `default_manifest`, `base_instructions`, `capabilities`, and `run_as` for workspace-scoped runs. See [Sandbox agent concepts](sandbox/guide.md).
## Prompt templates
You can reference a prompt template created in the OpenAI platform by setting `prompt`. This works with OpenAI models using the Responses API.
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+4
View File
@@ -2,9 +2,13 @@
This page covers SDK-wide defaults that you usually set once during application startup, such as the default OpenAI key or client, the default OpenAI API shape, tracing export defaults, and logging behavior.
These defaults still apply to sandbox-based workflows, but sandbox workspaces, sandbox clients, and session reuse are configured separately.
If you need to configure a specific agent or run instead, start with:
- [Agents](agents.md) for instructions, tools, output types, handoffs, and guardrails on a plain `Agent`.
- [Running agents](running_agents.md) for `RunConfig`, sessions, and conversation-state options.
- [Sandbox agents](sandbox/guide.md) for `SandboxRunConfig`, manifests, capabilities, and sandbox-client-specific workspace setup.
- [Models](models/index.md) for model selection and provider configuration.
- [Tracing](tracing.md) for per-run tracing metadata and custom trace processors.
+20
View File
@@ -20,6 +20,7 @@ Here are the main features of the SDK:
- **Agent loop**: A built-in agent loop that handles tool invocation, sends results back to the LLM, and continues until the task is complete.
- **Python-first**: Use built-in language features to orchestrate and chain agents, rather than needing to learn new abstractions.
- **Agents as tools / Handoffs**: A powerful mechanism for coordinating and delegating work across multiple agents.
- **Sandbox agents**: Run specialists inside real isolated workspaces with manifest-defined files, sandbox client choice, and resumable sandbox sessions.
- **Guardrails**: Run input validation and safety checks in parallel with agent execution, and fail fast when checks do not pass.
- **Function tools**: Turn any Python function into a tool with automatic schema generation and Pydantic-powered validation.
- **MCP server tool calling**: Built-in MCP server tool integration that works the same way as function tools.
@@ -28,6 +29,23 @@ Here are the main features of the SDK:
- **Tracing**: Built-in tracing for visualizing, debugging, and monitoring workflows, with support for the OpenAI suite of evaluation, fine-tuning, and distillation tools.
- **Realtime Agents**: Build powerful voice agents with `gpt-realtime-1.5`, automatic interruption detection, context management, guardrails, and more.
## Agents SDK or Responses API?
The SDK uses the Responses API by default for OpenAI models, but it adds a higher-level runtime around model calls.
Use the Responses API directly when:
- you want to own the loop, tool dispatch, and state handling yourself
- your workflow is short-lived and mainly about returning the model's response
Use the Agents SDK when:
- you want the runtime to manage turns, tool execution, guardrails, handoffs, or sessions
- your agent should produce artifacts or operate across multiple coordinated steps
- you need a real workspace or resumable execution through [Sandbox agents](sandbox_agents.md)
You do not need to choose one globally. Many applications use the SDK for managed workflows and call the Responses API directly for lower-level paths.
## Installation
```bash
@@ -59,6 +77,7 @@ export OPENAI_API_KEY=sk-...
- Build your first text-based agent with the [Quickstart](quickstart.md).
- Then decide how you want to carry state across turns in [Running agents](running_agents.md#choose-a-memory-strategy).
- If the task depends on real files, repos, or isolated per-agent workspace state, read the [Sandbox agents quickstart](sandbox_agents.md).
- If you are deciding between handoffs and manager-style orchestration, read [Agent orchestration](multi_agent.md).
## Choose your path
@@ -69,6 +88,7 @@ Use this table when you know the job you want to do, but not which page explains
| --- | --- |
| Build the first text agent and see one complete run | [Quickstart](quickstart.md) |
| Add function tools, hosted tools, or agents as tools | [Tools](tools.md) |
| Run a coding, review, or document agent inside a real isolated workspace | [Sandbox agents quickstart](sandbox_agents.md) and [Sandbox clients](sandbox/clients.md) |
| Decide between handoffs and manager-style orchestration | [Agent orchestration](multi_agent.md) |
| Keep memory across turns | [Running agents](running_agents.md#choose-a-memory-strategy) and [Sessions](sessions/index.md) |
| Use OpenAI models, websocket transport, or non-OpenAI providers | [Models](models/index.md) |
+3
View File
@@ -78,6 +78,8 @@ Use this rule of thumb:
For the tradeoffs and exact behaviors, see [Running agents](running_agents.md#choose-a-memory-strategy).
Use a plain `Agent` plus `Runner` when the task mainly lives in prompts, tools, and conversation state. If the agent should inspect or modify real files in an isolated workspace, jump to the [Sandbox agents quickstart](sandbox_agents.md).
## Give your agent tools
You can give an agent tools to look up information or perform actions.
@@ -191,4 +193,5 @@ Learn how to build more complex agentic flows:
- Learn about how to configure [Agents](agents.md).
- Learn about [running agents](running_agents.md) and [sessions](sessions/index.md).
- Learn about [Sandbox agents](sandbox_agents.md) if the work should happen inside a real workspace.
- Learn about [tools](tools.md), [guardrails](guardrails.md) and [models](models/index.md).
+9
View File
@@ -0,0 +1,9 @@
# `Sandbox`
::: agents.sandbox
options:
members:
- SandboxAgent
- Manifest
- SandboxRunConfig
- Capability
@@ -0,0 +1,6 @@
# `Capabilities`
::: agents.sandbox.capabilities.capabilities
options:
members:
- Capabilities
@@ -0,0 +1,6 @@
# `Capability`
::: agents.sandbox.capabilities.capability
options:
members:
- Capability
@@ -0,0 +1,10 @@
# `Compaction`
::: agents.sandbox.capabilities.compaction
options:
members:
- Compaction
- CompactionModelInfo
- CompactionPolicy
- DynamicCompactionPolicy
- StaticCompactionPolicy
@@ -0,0 +1,7 @@
# `Filesystem`
::: agents.sandbox.capabilities.filesystem
options:
members:
- Filesystem
- FilesystemToolSet
+6
View File
@@ -0,0 +1,6 @@
# `Memory`
::: agents.sandbox.capabilities.memory
options:
members:
- Memory
+7
View File
@@ -0,0 +1,7 @@
# `Shell`
::: agents.sandbox.capabilities.shell
options:
members:
- Shell
- ShellToolSet
+10
View File
@@ -0,0 +1,10 @@
# `Skills`
::: agents.sandbox.capabilities.skills
options:
members:
- Skills
- Skill
- SkillMetadata
- LazySkillSource
- LocalDirLazySkillSource
+16
View File
@@ -0,0 +1,16 @@
# `Workspace entries`
::: agents.sandbox.entries
options:
members:
- Dir
- File
- GitRepo
- LocalDir
- LocalFile
- Mount
- AzureBlobMount
- GCSMount
- R2Mount
- S3Mount
- S3FilesMount
+10
View File
@@ -0,0 +1,10 @@
# `Manifest`
::: agents.sandbox.manifest
options:
members:
- Manifest
- Environment
- EnvEntry
- EnvValue
- StrEnvValue
+9
View File
@@ -0,0 +1,9 @@
# `Permissions`
::: agents.sandbox.types
options:
members:
- User
- Group
- Permissions
- FileMode
+6
View File
@@ -0,0 +1,6 @@
# `SandboxAgent`
::: agents.sandbox.sandbox_agent
options:
members:
- SandboxAgent
+9
View File
@@ -0,0 +1,9 @@
# `Docker sandbox`
::: agents.sandbox.sandboxes.docker
options:
members:
- DockerSandboxClient
- DockerSandboxClientOptions
- DockerSandboxSession
- DockerSandboxSessionState
+9
View File
@@ -0,0 +1,9 @@
# `Unix local sandbox`
::: agents.sandbox.sandboxes.unix_local
options:
members:
- UnixLocalSandboxClient
- UnixLocalSandboxClientOptions
- UnixLocalSandboxSession
- UnixLocalSandboxSessionState
@@ -0,0 +1,7 @@
# `Sandbox clients`
::: agents.sandbox.session.sandbox_client
options:
members:
- BaseSandboxClient
- BaseSandboxClientOptions
@@ -0,0 +1,6 @@
# `SandboxSession`
::: agents.sandbox.session.sandbox_session
options:
members:
- SandboxSession
@@ -0,0 +1,6 @@
# `SandboxSessionState`
::: agents.sandbox.session.sandbox_session_state
options:
members:
- SandboxSessionState
+11
View File
@@ -0,0 +1,11 @@
# `SnapshotSpec`
::: agents.sandbox.snapshot
options:
members:
- SnapshotSpec
- LocalSnapshotSpec
- RemoteSnapshotSpec
- LocalSnapshot
- RemoteSnapshot
- resolve_snapshot
+1 -1
View File
@@ -143,7 +143,7 @@ Use `RunConfig` to override behavior for a single run without changing each agen
##### Tracing and observability
- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: Allows you to disable [tracing](tracing.md) for the entire run.
- [`tracing`][agents.run.RunConfig.tracing]: Pass a [`TracingConfig`][agents.tracing.TracingConfig] to override exporters, processors, or tracing metadata for this run.
- [`tracing`][agents.run.RunConfig.tracing]: Pass a [`TracingConfig`][agents.tracing.TracingConfig] to override trace export settings such as the per-run tracing API key.
- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: Configures whether traces will include potentially sensitive data, such as LLM and tool call inputs/outputs.
- [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: Sets the tracing workflow name, trace ID and trace group ID for the run. We recommend at least setting `workflow_name`. The group ID is an optional field that lets you link traces across multiple runs.
- [`trace_metadata`][agents.run.RunConfig.trace_metadata]: Metadata to include on all traces.
+137
View File
@@ -0,0 +1,137 @@
# Sandbox clients
Use this page to choose where sandbox work should run. In most cases, the `SandboxAgent` definition stays the same while the sandbox client and client-specific options change in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig].
!!! warning "Beta feature"
Sandbox agents are in beta. Expect details of the API, defaults, and supported capabilities to change before general availability, and expect more advanced features over time.
## Decision guide
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Goal | Start with | Why |
| --- | --- | --- |
| Fastest local iteration on macOS or Linux | `UnixLocalSandboxClient` | No extra install, simple local filesystem development. |
| Basic container isolation | `DockerSandboxClient` | Runs work inside Docker with a specific image. |
| Hosted execution or production-style isolation | A hosted sandbox client | Moves the workspace boundary to a provider-managed environment. |
</div>
## Local clients
For most users, start with one of these two sandbox clients:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Client | Install | Choose it when | Example |
| --- | --- | --- | --- |
| `UnixLocalSandboxClient` | none | Fastest local iteration on macOS or Linux. Good default for local development. | [Unix-local starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) |
| `DockerSandboxClient` | `openai-agents[docker]` | You want container isolation or a specific image for local parity. | [Docker starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
</div>
Unix-local is the easiest way to start developing against a local filesystem. Move to Docker or a hosted provider when you need stronger environment isolation or production-style parity.
To switch from Unix-local to Docker, keep the agent definition the same and change only the run config:
```python
from docker import from_env as docker_from_env
from agents.run import RunConfig
from agents.sandbox import SandboxRunConfig
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=DockerSandboxClient(docker_from_env()),
options=DockerSandboxClientOptions(image="python:3.14-slim"),
),
)
```
Use this when you want container isolation or image parity. See [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py).
## Mounts and remote storage
Mount entries describe what storage to expose; mount strategies describe how a sandbox backend attaches that storage. Import the built-in mount entries and generic strategies from `agents.sandbox.entries`. Hosted-provider strategies are available from `agents.extensions.sandbox` or the provider-specific extension package.
Common mount options:
- `mount_path`: where the storage appears in the sandbox. Relative paths are resolved under the manifest root; absolute paths are used as-is.
- `read_only`: defaults to `True`. Set `False` only when the sandbox should write back to the mounted storage.
- `mount_strategy`: required. Use a strategy that matches both the mount entry and the sandbox backend.
Mounts are treated as ephemeral workspace entries. Snapshot and persistence flows detach or skip mounted paths instead of copying mounted remote storage into the saved workspace.
Generic local/container strategies:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Strategy or pattern | Use it when | Notes |
| --- | --- | --- |
| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | The sandbox image can run `rclone`. | Supports S3, GCS, R2, and Azure Blob. `RcloneMountPattern` can run in `fuse` mode or `nfs` mode. |
| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | The image has `mount-s3` and you want Mountpoint-style S3 or S3-compatible access. | Supports `S3Mount` and `GCSMount`. |
| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | The image has `blobfuse2` and FUSE support. | Supports `AzureBlobMount`. |
| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | The image has `mount.s3files` and can reach an existing S3 Files mount target. | Supports `S3FilesMount`. |
| `DockerVolumeMountStrategy(driver=...)` | Docker should attach a volume-driver-backed mount before the container starts. | Docker-only. S3, GCS, R2, and Azure Blob support `rclone`; S3 and GCS also support `mountpoint`. |
</div>
## Supported hosted platforms
When you need a hosted environment, the same `SandboxAgent` definition usually carries over and only the sandbox client changes in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig].
If you are using the published SDK instead of this repository checkout, install sandbox-client dependencies through the matching package extra.
For provider-specific setup notes and links for the checked-in extension examples, see [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md).
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Client | Install | Example |
| --- | --- | --- |
| `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) |
| `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) |
| `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) |
| `E2BSandboxClient` | `openai-agents[e2b]` | [E2B runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) |
| `ModalSandboxClient` | `openai-agents[modal]` | [Modal runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) |
| `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) |
| `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) |
</div>
Hosted sandbox clients expose provider-specific mount strategies. Choose the backend and mount strategy that best fit your storage provider:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Backend | Mount notes |
| --- | --- |
| Docker | Supports `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `S3FilesMount` with local strategies such as `InContainerMountStrategy` and `DockerVolumeMountStrategy`. |
| `ModalSandboxClient` | Supports Modal cloud bucket mounts with `ModalCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. You can use inline credentials or a named Modal Secret. |
| `CloudflareSandboxClient` | Supports Cloudflare bucket mounts with `CloudflareBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. |
| `BlaxelSandboxClient` | Supports cloud bucket mounts with `BlaxelCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and `GCSMount`. Also supports persistent Blaxel Drives with `BlaxelDriveMount` and `BlaxelDriveMountStrategy` from `agents.extensions.sandbox.blaxel`. |
| `DaytonaSandboxClient` | Supports cloud bucket mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
| `E2BSandboxClient` | Supports cloud bucket mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
| `RunloopSandboxClient` | Supports cloud bucket mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
| `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. |
</div>
The table below summarizes which remote storage entries each backend can mount directly.
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Backend | AWS S3 | Cloudflare R2 | GCS | Azure Blob Storage | S3 Files |
| --- | --- | --- | --- | --- | --- |
| Docker | ✓ | ✓ | ✓ | ✓ | ✓ |
| `ModalSandboxClient` | ✓ | ✓ | ✓ | - | - |
| `CloudflareSandboxClient` | ✓ | ✓ | ✓ | - | - |
| `BlaxelSandboxClient` | ✓ | ✓ | ✓ | - | - |
| `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | - |
| `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | - |
| `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | - |
| `VercelSandboxClient` | - | - | - | - | - |
</div>
For more runnable examples, browse [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox) for local, coding, memory, handoff, and agent-composition patterns, and [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions) for hosted sandbox clients.
+832
View File
@@ -0,0 +1,832 @@
# Concepts
!!! warning "Beta feature"
Sandbox agents are in beta. Expect details of the API, defaults, and supported capabilities to change before general availability, and expect more advanced features over time.
Modern agents work best when they can operate on real files in a filesystem. **Sandbox Agents** can make use of specialized tools and shell commands to search over and manipulate large document sets, edit files, generate artifacts, and run commands. The sandbox provides the model with a persistent workspace that the agent can use to do work on your behalf. Sandbox Agents in the Agents SDK help you easily run agents paired with a sandbox environment, making it easy to get the right files on the filesystem and orchestrate the sandboxes to make it easy to start, stop, and resume tasks at scale.
You define the workspace around the data the agent needs. It can start from GitHub repos, local files and directories, synthetic task files, remote filesystems such as S3 or Azure Blob Storage, and other sandbox inputs you provide.
<div class="sandbox-harness-image" markdown="1">
![Sandbox agent harness with compute](../assets/images/harness_with_compute.png)
</div>
`SandboxAgent` is still an `Agent`. It keeps the usual agent surface such as `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, guardrails, and hooks, and it still runs through the normal `Runner` APIs. What changes is the execution boundary:
- `SandboxAgent` defines the agent itself: the usual agent configuration plus sandbox-specific defaults like `default_manifest`, `base_instructions`, `run_as`, and capabilities such as filesystem tools, shell access, skills, memory, or compaction.
- `Manifest` declares the desired starting contents and layout for a fresh sandbox workspace, including files, repos, mounts, and environment.
- A sandbox session is the live isolated environment where commands run and files change.
- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] decides how the run gets that sandbox session, for example by injecting one directly, reconnecting from serialized sandbox session state, or creating a fresh sandbox session through a sandbox client.
- Saved sandbox state and snapshots let later runs reconnect to prior work or seed a fresh sandbox session from saved contents.
`Manifest` is the fresh-session workspace contract, not the full source of truth for every live sandbox. The effective workspace for a run can instead come from a reused sandbox session, serialized sandbox session state, or a snapshot chosen at run time.
Throughout this page, "sandbox session" means the live execution environment managed by a sandbox client. It is different from the SDK's conversational [`Session`][agents.memory.session.Session] interfaces described in [Sessions](../sessions/index.md).
The outer runtime still owns approvals, tracing, handoffs, and resume bookkeeping. The sandbox session owns commands, file changes, and environment isolation. That split is a core part of the model.
### How the pieces fit together
A sandbox run combines an agent definition with per-run sandbox configuration. The runner prepares the agent, binds it to a live sandbox session, and can save state for later runs.
```mermaid
flowchart LR
agent["SandboxAgent<br/><small>full Agent + sandbox defaults</small>"]
config["SandboxRunConfig<br/><small>client / session / resume inputs</small>"]
runner["Runner<br/><small>prepare instructions<br/>bind capability tools</small>"]
sandbox["sandbox session<br/><small>workspace where commands run<br/>and files change</small>"]
saved["saved state / snapshot<br/><small>for resume or fresh-start later</small>"]
agent --> runner
config --> runner
runner --> sandbox
sandbox --> saved
```
Sandbox-specific defaults stay on `SandboxAgent`. Per-run sandbox-session choices stay in `SandboxRunConfig`.
Think about the lifecycle in three phases:
1. Define the agent and the fresh-workspace contract with `SandboxAgent`, `Manifest`, and capabilities.
2. Execute a run by giving `Runner` a `SandboxRunConfig` that injects, resumes, or creates the sandbox session.
3. Continue later from runner-managed `RunState`, explicit sandbox `session_state`, or a saved workspace snapshot.
If shell access is only one occasional tool, start with hosted shell in the [tools guide](../tools.md). Reach for sandbox agents when workspace isolation, sandbox client choice, or sandbox-session resume behavior are part of the design.
## When to use them
Sandbox agents are a good fit for workspace-centric workflows, for example:
- coding and debugging, for example orchestrating automated fixes for issue reports in a GitHub repo and running targeted tests
- document processing and editing, for example extracting information from a user's financial documents and creating a completed tax-form draft
- file-grounded review or analysis, for example checking onboarding packets, generated reports, or artifact bundles before answering
- isolated multi-agent patterns, for example giving each reviewer or coding sub-agent its own workspace
- multi-step workspace tasks, for example fixing a bug in one run and adding a regression test later, or resuming from snapshot or sandbox session state
If you do not need access to files or a living filesystem, keep using `Agent`. If shell access is just one occasional capability, add hosted shell; if the workspace boundary itself is part of the feature, use sandbox agents.
## Choose a sandbox client
Start with `UnixLocalSandboxClient` for local development. Move to `DockerSandboxClient` when you need container isolation or image parity. Move to a hosted provider when you need provider-managed execution.
In most cases, the `SandboxAgent` definition stays the same while the sandbox client and its options change in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]. See [Sandbox clients](clients.md) for local, Docker, hosted, and remote-mount options.
## Core pieces
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Layer | Main SDK pieces | What it answers |
| --- | --- | --- |
| Agent definition | `SandboxAgent`, `Manifest`, capabilities | What agent will run, and what fresh-session workspace contract should it start from? |
| Sandbox execution | `SandboxRunConfig`, the sandbox client, and the live sandbox session | How does this run get a live sandbox session, and where does the work execute? |
| Saved sandbox state | `RunState` sandbox payload, `session_state`, and snapshots | How does this workflow reconnect to prior sandbox work or seed a fresh sandbox session from saved contents? |
</div>
The main SDK pieces map onto those layers like this:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Piece | What it owns | Ask this question |
| --- | --- | --- |
| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | The agent definition | What should this agent do, and which defaults should travel with it? |
| [`Manifest`][agents.sandbox.manifest.Manifest] | Fresh-session workspace files and folders | What files and folder should be present on the filesystem when the run starts? |
| [`Capability`][agents.sandbox.capabilities.capability.Capability] | Sandbox-native behavior | Which tools, instruction fragments, or runtime behavior should attach to this agent? |
| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | Per-run sandbox client and sandbox-session source | Should this run inject, resume, or create a sandbox session? |
| [`RunState`][agents.run_state.RunState] | Runner-managed saved sandbox state | Am I resuming a prior runner-managed workflow and carrying its sandbox state forward automatically? |
| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | Explicit serialized sandbox session state | Do I want to resume from sandbox state I already serialized outside `RunState`? |
| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | Saved workspace contents for fresh sandbox sessions | Should a new sandbox session start from saved files and artifacts? |
</div>
A practical design order is:
1. Define the fresh-session workspace contract with `Manifest`.
2. Define the agent with `SandboxAgent`.
3. Add built-in or custom capabilities.
4. Decide how each run should obtain its sandbox session in `RunConfig(sandbox=SandboxRunConfig(...))`.
## How a sandbox run is prepared
At run time, the runner turns that definition into a concrete sandbox-backed run:
1. It resolves the sandbox session from `SandboxRunConfig`.
If you pass `session=...`, it reuses that live sandbox session.
Otherwise it uses `client=...` to create or resume one.
2. It determines the effective workspace inputs for the run.
If the run injects or resumes a sandbox session, that existing sandbox state wins.
Otherwise the runner starts from a one-off manifest override or `agent.default_manifest`.
This is why `Manifest` alone does not define the final live workspace for every run.
3. It lets capabilities process the resulting manifest.
This is how capabilities can add files, mounts, or other workspace-scoped behavior before the final agent is prepared.
4. It builds the final instructions in a fixed order:
the SDK's default sandbox prompt, or `base_instructions` if you explicitly override it, then `instructions`, then capability instruction fragments, then any remote-mount policy text, then a rendered filesystem tree.
5. It binds capability tools to the live sandbox session and runs the prepared agent through the normal `Runner` APIs.
Sandboxing does not change what a turn means. A turn is still a model step, not a single shell command or sandbox action. There is no fixed 1:1 mapping between sandbox-side operations and turns: some work may stay inside the sandbox execution layer, while other actions return tool results, approvals, or other state that requires another model step. As a practical rule, another turn is consumed only when the agent runtime needs another model response after sandbox work has happened.
Those preparation steps are why `default_manifest`, `instructions`, `base_instructions`, `capabilities`, and `run_as` are the main sandbox-specific options to think about when designing a `SandboxAgent`.
## `SandboxAgent` options
These are the sandbox-specific options on top of the usual `Agent` fields:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Option | Best use |
| --- | --- |
| `default_manifest` | The default workspace for fresh sandbox sessions created by the runner. |
| `instructions` | Additional role, workflow, and success criteria appended after the SDK sandbox prompt. |
| `base_instructions` | Advanced escape hatch that replaces the SDK sandbox prompt. |
| `capabilities` | Sandbox-native tools and behavior that should travel with this agent. |
| `run_as` | User identity for model-facing sandbox tools such as shell commands, file reads, and patches. |
</div>
Sandbox client choice, sandbox-session reuse, manifest override, and snapshot selection belong in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig], not on the agent.
### `default_manifest`
`default_manifest` is the default [`Manifest`][agents.sandbox.manifest.Manifest] used when the runner creates a fresh sandbox session for this agent. Use it for the files, repos, helper material, output directories, and mounts the agent should usually start with.
This is only the default. A run can override it with `SandboxRunConfig(manifest=...)`, and a reused or resumed sandbox session keeps its existing workspace state.
### `instructions` and `base_instructions`
Use `instructions` for short rules that should survive different prompts. In a `SandboxAgent`, these instructions are appended after the SDK's sandbox base prompt, so you keep the built-in sandbox guidance and add your own role, workflow, and success criteria.
Use `base_instructions` only when you want to replace the SDK sandbox base prompt. Most agents should not set it.
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Put it in... | Use it for | Examples |
| --- | --- | --- |
| `instructions` | Stable role, workflow rules, and success criteria for the agent. | "Inspect onboarding documents, then hand off.", "Write final files into `output/`." |
| `base_instructions` | A full replacement for the SDK sandbox base prompt. | Custom low-level sandbox wrapper prompts. |
| the user prompt | The one-off request for this run. | "Summarize this workspace." |
| workspace files in the manifest | Longer task specs, repo-local instructions, or bounded reference material. | `repo/task.md`, document bundles, sample packets. |
</div>
Good uses for `instructions` include:
- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) keeps the agent in one interactive process when PTY state matters.
- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) forbids the sandbox reviewer from answering the user directly after inspection.
- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) requires the final filled files to actually land in `output/`.
- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) pins the exact verification command and clarifies workspace-root-relative patch paths.
Avoid copying the user's one-off task into `instructions`, embedding long reference material that belongs in the manifest, restating tool docs that built-in capabilities already inject, or mixing in local installation notes the model does not need at run time.
If you omit `instructions`, the SDK still includes the default sandbox prompt. That is enough for low-level wrappers, but most user-facing agents should still provide explicit `instructions`.
### `capabilities`
Capabilities attach sandbox-native behavior to a `SandboxAgent`. They can shape the workspace before a run starts, append sandbox-specific instructions, expose tools that bind to the live sandbox session, and adjust model behavior or input handling for that agent.
Built-in capabilities include:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Capability | Add it when | Notes |
| --- | --- | --- |
| `Shell` | The agent needs shell access. | Adds `exec_command`, plus `write_stdin` when the sandbox client supports PTY interaction. |
| `Filesystem` | The agent needs to edit files or inspect local images. | Adds `apply_patch` and `view_image`; patch paths are workspace-root-relative. |
| `Skills` | You want skill discovery and materialization in the sandbox. | Prefer this over mounting `.agents` or `.agents/skills` manually for sandbox-local `SKILL.md` skills. |
| `Memory` | Follow-on runs should read or generate memory artifacts. | Requires `Shell`; live updates also require `Filesystem`. |
| `Compaction` | Long-running flows need context trimming after compaction items. | Adjusts model sampling and input handling. |
</div>
By default, `SandboxAgent.capabilities` uses `Capabilities.default()`, which includes `Filesystem()`, `Shell()`, and `Compaction()`. If you pass `capabilities=[...]`, that list replaces the default, so include any default capabilities you still want.
For skills, choose the source based on how you want them materialized:
- `Skills(lazy_from=LocalDirLazySkillSource(...))` is a good default for larger local skill directories because the model can discover the index first and load only what it needs.
- `Skills(from_=LocalDir(src=...))` is better for a small local bundle you want staged up front.
- `Skills(from_=GitRepo(repo=..., ref=...))` is the right fit when the skills themselves should come from a repository.
If your skills already live on disk under something like `.agents/skills/<name>/SKILL.md`, point `LocalDir(...)` at that source root and still use `Skills(...)` to expose them. Keep the default `skills_path=".agents"` unless you have an existing workspace contract that depends on a different in-sandbox layout.
Prefer built-in capabilities when they fit. Write a custom capability only when you need a sandbox-specific tool or instruction surface that the built-ins do not cover.
## Concepts
### Manifest
A [`Manifest`][agents.sandbox.manifest.Manifest] describes the workspace for a fresh sandbox session. It can set the workspace `root`, declare files and directories, copy in local files, clone Git repos, attach remote storage mounts, set environment variables, and define users or groups.
Manifest entry paths are workspace-relative. They cannot be absolute paths or escape the workspace with `..`, which keeps the workspace contract portable across local, Docker, and hosted clients.
Use manifest entries for the material the agent needs before work begins:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Manifest entry | Use it for |
| --- | --- |
| `File`, `Dir` | Small synthetic inputs, helper files, or output directories. |
| `LocalFile`, `LocalDir` | Host files or directories that should be materialized into the sandbox. |
| `GitRepo` | A repository that should be fetched into the workspace. |
| mounts such as `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `S3FilesMount` | External storage that should appear inside the sandbox. |
</div>
Mount entries describe what storage to expose; mount strategies describe how a sandbox backend attaches that storage. See [Sandbox clients](clients.md#mounts-and-remote-storage) for mount options and provider support.
Good manifest design usually means keeping the workspace contract narrow, putting long task recipes in workspace files such as `repo/task.md`, and using relative workspace paths in instructions, for example `repo/task.md` or `output/report.md`. If the agent edits files with the `Filesystem` capability's `apply_patch` tool, remember that patch paths are relative to the sandbox workspace root, not the shell `workdir`.
### Permissions
`Permissions` controls filesystem permissions for manifest entries. It is about the files the sandbox materializes, not model permissions, approval policy, or API credentials.
By default, manifest entries are owner-readable/writable/executable and readable/executable by group and others. Override this when staged files should be private, read-only, or executable:
```python
from agents.sandbox import FileMode, Permissions
from agents.sandbox.entries import File
private_notes = File(
text="internal notes",
permissions=Permissions(
owner=FileMode.READ | FileMode.WRITE,
group=FileMode.NONE,
other=FileMode.NONE,
),
)
```
`Permissions` stores separate owner, group, and other bits, plus whether the entry is a directory. You can build it directly, parse it from a mode string with `Permissions.from_str(...)`, or derive it from an OS mode with `Permissions.from_mode(...)`.
Users are the sandbox identities that can execute work. Add a `User` to the manifest when you want that identity to exist in the sandbox, then set `SandboxAgent.run_as` when model-facing sandbox tools such as shell commands, file reads, and patches should run as that user. If `run_as` points at a user that is not already in the manifest, the runner adds it to the effective manifest for you.
```python
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import FileMode, Manifest, Permissions, SandboxAgent, SandboxRunConfig, User
from agents.sandbox.entries import Dir, LocalDir
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
analyst = User(name="analyst")
agent = SandboxAgent(
name="Dataroom analyst",
instructions="Review the files in `dataroom/` and write findings to `output/`.",
default_manifest=Manifest(
# Declare the sandbox user so manifest entries can grant access to it.
users=[analyst],
entries={
"dataroom": LocalDir(
src="./dataroom",
# Let the analyst traverse and read the mounted dataroom, but not edit it.
group=analyst,
permissions=Permissions(
owner=FileMode.READ | FileMode.EXEC,
group=FileMode.READ | FileMode.EXEC,
other=FileMode.NONE,
),
),
"output": Dir(
# Give the analyst a writable scratch/output directory for artifacts.
group=analyst,
permissions=Permissions(
owner=FileMode.ALL,
group=FileMode.ALL,
other=FileMode.NONE,
),
),
},
),
# Run model-facing sandbox actions as this user, so those permissions apply.
run_as=analyst,
)
result = await Runner.run(
agent,
"Summarize the contracts and call out renewal dates.",
run_config=RunConfig(
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
),
)
```
If you also need file-level sharing rules, combine users with manifest groups and entry `group` metadata. The `run_as` user controls who executes sandbox-native actions; `Permissions` controls which files that user can read, write, or execute once the sandbox has materialized the workspace.
### SnapshotSpec
`SnapshotSpec` tells a fresh sandbox session where saved workspace contents should be restored from and persisted back to. It is the snapshot policy for the sandbox workspace, while `session_state` is the serialized connection state for resuming a specific sandbox backend.
Use `LocalSnapshotSpec` for local durable snapshots and `RemoteSnapshotSpec` when your app provides a remote snapshot client. A no-op snapshot is used as a fallback when local snapshot setup is unavailable, and advanced callers can use one explicitly when they do not want workspace snapshot persistence.
```python
from pathlib import Path
from agents.run import RunConfig
from agents.sandbox import LocalSnapshotSpec, SandboxRunConfig
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=UnixLocalSandboxClient(),
snapshot=LocalSnapshotSpec(base_path=Path("/tmp/my-sandbox-snapshots")),
)
)
```
When the runner creates a fresh sandbox session, the sandbox client builds a snapshot instance for that session. On start, if the snapshot is restorable, the sandbox restores saved workspace contents before the run continues. On cleanup, runner-owned sandbox sessions archive the workspace and persist it back through the snapshot.
If you omit `snapshot`, the runtime tries to use a default local snapshot location when it can. If that cannot be set up, it falls back to a no-op snapshot. Mounted and ephemeral paths are not copied into snapshots as durable workspace contents.
### Sandbox lifecycle
There are two lifecycle modes: **SDK-owned** and **developer-owned**.
<div class="sandbox-lifecycle-diagram" markdown="1">
```mermaid
sequenceDiagram
participant App
participant Runner
participant Client
participant Sandbox
App->>Runner: Runner.run(..., SandboxRunConfig(client=...))
Runner->>Client: create or resume sandbox
Client-->>Runner: sandbox session
Runner->>Sandbox: start, run tools
Runner->>Sandbox: stop and persist snapshot
Runner->>Client: delete runner-owned resources
App->>Client: create(...)
Client-->>App: sandbox session
App->>Sandbox: async with sandbox
App->>Runner: Runner.run(..., SandboxRunConfig(session=sandbox))
Runner->>Sandbox: run tools
App->>Sandbox: cleanup on context exit / aclose()
```
</div>
Use SDK-owned lifecycle when the sandbox only needs to live for one run. Pass a `client`, optional `manifest`, optional `snapshot`, and client `options`; the runner creates or resumes the sandbox, starts it, runs the agent, persists snapshot-backed workspace state, shuts the sandbox down, and lets the client clean up runner-owned resources.
```python
result = await Runner.run(
agent,
"Inspect the workspace and summarize what changed.",
run_config=RunConfig(
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
),
)
```
Use developer-owned lifecycle when you want to eagerly create a sandbox, reuse one live sandbox across multiple runs, inspect files after a run, stream over a sandbox you created yourself, or decide exactly when cleanup happens. Passing `session=...` tells the runner to use that live sandbox, but not to close it for you.
```python
sandbox = await client.create(manifest=agent.default_manifest)
async with sandbox:
run_config = RunConfig(sandbox=SandboxRunConfig(session=sandbox))
await Runner.run(agent, "Analyze the files.", run_config=run_config)
await Runner.run(agent, "Write the final report.", run_config=run_config)
```
The context manager is the usual shape: it starts the sandbox on entry and runs the session cleanup lifecycle on exit. If your app cannot use a context manager, call the lifecycle methods directly:
```python
sandbox = await client.create(
manifest=agent.default_manifest,
snapshot=LocalSnapshotSpec(base_path=Path("/tmp/my-sandbox-snapshots")),
)
try:
await sandbox.start()
await Runner.run(
agent,
"Analyze the files.",
run_config=RunConfig(sandbox=SandboxRunConfig(session=sandbox)),
)
# Persist a checkpoint of the live workspace before doing more work.
# `aclose()` also calls `stop()`, so this is only needed for an explicit mid-lifecycle save.
await sandbox.stop()
finally:
await sandbox.aclose()
```
`stop()` only persists snapshot-backed workspace contents; it does not tear down the sandbox. `aclose()` is the full session cleanup path: it runs pre-stop hooks, calls `stop()`, shuts down sandbox resources, and closes session-scoped dependencies.
## `SandboxRunConfig` options
[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] holds the per-run options that decide where the sandbox session comes from and how a fresh session should be initialized.
### Sandbox source
These options decide whether the runner should reuse, resume, or create the sandbox session:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Option | Use it when | Notes |
| --- | --- | --- |
| `client` | You want the runner to create, resume, and clean up sandbox sessions for you. | Required unless you provide a live sandbox `session`. |
| `session` | You already created a live sandbox session yourself. | The caller owns lifecycle; the runner reuses that live sandbox session. |
| `session_state` | You have serialized sandbox session state but not a live sandbox session object. | Requires `client`; the runner resumes from that explicit state as an owning session. |
</div>
In practice, the runner resolves the sandbox session in this order:
1. If you inject `run_config.sandbox.session`, that live sandbox session is reused directly.
2. Otherwise, if the run is resuming from `RunState`, the stored sandbox session state is resumed.
3. Otherwise, if you pass `run_config.sandbox.session_state`, the runner resumes from that explicit serialized sandbox session state.
4. Otherwise, the runner creates a fresh sandbox session. For that fresh session, it uses `run_config.sandbox.manifest` when provided, or `agent.default_manifest` if not.
### Fresh-session inputs
These options only matter when the runner is creating a fresh sandbox session:
<div class="sandbox-nowrap-first-column-table" markdown="1">
| Option | Use it when | Notes |
| --- | --- | --- |
| `manifest` | You want a one-off fresh-session workspace override. | Falls back to `agent.default_manifest` when omitted. |
| `snapshot` | A fresh sandbox session should be seeded from a snapshot. | Useful for resume-like flows or remote snapshot clients. |
| `options` | The sandbox client needs creation-time options. | Common for Docker images, Modal app names, E2B templates, timeouts, and similar client-specific settings. |
</div>
### Materialization controls
`concurrency_limits` controls how much sandbox materialization work can run in parallel. Use `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` when large manifests or local directory copies need tighter resource control. Set either value to `None` to disable that specific limit.
A few implications are worth keeping in mind:
- Fresh sessions: `manifest=` and `snapshot=` only apply when the runner is creating a fresh sandbox session.
- Resume vs snapshot: `session_state=` reconnects to previously serialized sandbox state, whereas `snapshot=` seeds a new sandbox session from saved workspace contents.
- Client-specific options: `options=` depends on the sandbox client; Docker and many hosted clients require it.
- Injected live sessions: if you pass a running sandbox `session`, capability-driven manifest updates can add compatible non-mount entries. They cannot change `manifest.root`, `manifest.environment`, `manifest.users`, or `manifest.groups`; remove existing entries; replace entry types; or add or change mount entries.
- Runner API: `SandboxAgent` execution still uses the normal `Runner.run()`, `Runner.run_sync()`, and `Runner.run_streamed()` APIs.
## Full example: coding task
This coding-style example is a good default starting point:
```python
import asyncio
from pathlib import Path
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.capabilities import (
Capabilities,
LocalDirLazySkillSource,
Skills,
)
from agents.sandbox.entries import LocalDir
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
EXAMPLE_DIR = Path(__file__).resolve().parent
HOST_REPO_DIR = EXAMPLE_DIR / "repo"
HOST_SKILLS_DIR = EXAMPLE_DIR / "skills"
TARGET_TEST_CMD = "sh tests/test_credit_note.sh"
def build_agent(model: str) -> SandboxAgent[None]:
return SandboxAgent(
name="Sandbox engineer",
model=model,
instructions=(
"Inspect the repo, make the smallest correct change, run the most relevant checks, "
"and summarize the file changes and risks. "
"Read `repo/task.md` before editing files. Stay grounded in the repository, preserve "
"existing behavior, and mention the exact verification command you ran. "
"Use the `$credit-note-fixer` skill before editing files. If the repo lives under "
"`repo/`, remember that `apply_patch` paths stay relative to the sandbox workspace "
"root, so edits still target `repo/...`."
),
# Put repos and task files in the manifest.
default_manifest=Manifest(
entries={
"repo": LocalDir(src=HOST_REPO_DIR),
}
),
capabilities=Capabilities.default() + [
# Let Skills(...) stage and index sandbox-local skills for you.
Skills(
lazy_from=LocalDirLazySkillSource(
source=LocalDir(src=HOST_SKILLS_DIR),
)
),
],
model_settings=ModelSettings(tool_choice="required"),
)
async def main(model: str, prompt: str) -> None:
result = await Runner.run(
build_agent(model),
prompt,
run_config=RunConfig(
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
workflow_name="Sandbox coding example",
),
)
print(result.final_output)
if __name__ == "__main__":
asyncio.run(
main(
model="gpt-5.4",
prompt=(
"Open `repo/task.md`, use the `$credit-note-fixer` skill, fix the bug, "
f"run `{TARGET_TEST_CMD}`, and summarize the change."
),
)
)
```
See [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py). It uses a tiny shell-based repo so the example can be verified deterministically across Unix-local runs. Your real task repo can of course be Python, JavaScript, or anything else.
## Common patterns
Start from the full example above. In many cases, the same `SandboxAgent` can stay intact while only the sandbox client, sandbox-session source, or workspace source changes.
### Switch sandbox clients
Keep the agent definition the same and change only the run config. Use Docker when you want container isolation or image parity, or a hosted provider when you want provider-managed execution. See [Sandbox clients](clients.md) for examples and provider options.
### Override the workspace
Keep the agent definition the same and swap only the fresh-session manifest:
```python
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxRunConfig
from agents.sandbox.entries import GitRepo
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=UnixLocalSandboxClient(),
manifest=Manifest(
entries={
"repo": GitRepo(repo="openai/openai-agents-python", ref="main"),
}
),
),
)
```
Use this when the same agent role should run against different repos, packets, or task bundles without rebuilding the agent. The validated coding example above shows the same pattern with `default_manifest` instead of a one-off override.
### Inject a sandbox session
Inject a live sandbox session when you need explicit lifecycle control, post-run inspection, or output copying:
```python
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import SandboxRunConfig
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
client = UnixLocalSandboxClient()
sandbox = await client.create(manifest=agent.default_manifest)
async with sandbox:
result = await Runner.run(
agent,
prompt,
run_config=RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
),
)
```
Use this when you want to inspect the workspace after the run or stream over an already-started sandbox session. See [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) and [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py).
### Resume from session state
If you already serialized sandbox state outside `RunState`, let the runner reconnect from that state:
```python
from agents.run import RunConfig
from agents.sandbox import SandboxRunConfig
serialized = load_saved_payload()
restored_state = client.deserialize_session_state(serialized)
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=client,
session_state=restored_state,
),
)
```
Use this when sandbox state lives in your own storage or job system and you want `Runner` to resume from it directly. See [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) for the serialize/deserialize flow.
### Start from a snapshot
Seed a new sandbox from saved files and artifacts:
```python
from pathlib import Path
from agents.run import RunConfig
from agents.sandbox import LocalSnapshotSpec, SandboxRunConfig
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=UnixLocalSandboxClient(),
snapshot=LocalSnapshotSpec(base_path=Path("/tmp/my-sandbox-snapshot")),
),
)
```
Use this when a fresh run should start from saved workspace contents rather than only `agent.default_manifest`. See [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) for a local snapshot flow and [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) for a remote snapshot client.
### Load skills from Git
Swap the local skill source for a repository-backed one:
```python
from agents.sandbox.capabilities import Capabilities, Skills
from agents.sandbox.entries import GitRepo
capabilities = Capabilities.default() + [
Skills(from_=GitRepo(repo="sdcoffey/tax-prep-skills", ref="main")),
]
```
Use this when the skills bundle has its own release cadence or should be shared across sandboxes. See [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py).
### Expose as tools
Tool-agents can either get their own sandbox boundary or reuse a live sandbox from the parent run. Reuse is useful for a fast read-only explorer agent: it can inspect the exact workspace the parent is using without paying to create, hydrate, or snapshot another sandbox.
```python
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import FileMode, Manifest, Permissions, SandboxAgent, SandboxRunConfig, User
from agents.sandbox.entries import Dir, File
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
coordinator = User(name="coordinator")
explorer = User(name="explorer")
manifest = Manifest(
users=[coordinator, explorer],
entries={
"pricing_packet": Dir(
group=coordinator,
permissions=Permissions(
owner=FileMode.ALL,
group=FileMode.ALL,
other=FileMode.READ | FileMode.EXEC,
directory=True,
),
children={
"pricing.md": File(
content=b"Pricing packet contents...",
group=coordinator,
permissions=Permissions(
owner=FileMode.ALL,
group=FileMode.ALL,
other=FileMode.READ,
),
),
},
),
"work": Dir(
group=coordinator,
permissions=Permissions(
owner=FileMode.ALL,
group=FileMode.ALL,
other=FileMode.NONE,
directory=True,
),
),
},
)
pricing_explorer = SandboxAgent(
name="Pricing Explorer",
instructions="Read `pricing_packet/` and summarize commercial risk. Do not edit files.",
run_as=explorer,
)
client = UnixLocalSandboxClient()
sandbox = await client.create(manifest=manifest)
async with sandbox:
shared_run_config = RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
)
orchestrator = SandboxAgent(
name="Revenue Operations Coordinator",
instructions="Coordinate the review and write final notes to `work/`.",
run_as=coordinator,
tools=[
pricing_explorer.as_tool(
tool_name="review_pricing_packet",
tool_description="Inspect the pricing packet and summarize commercial risk.",
run_config=shared_run_config,
max_turns=2,
),
],
)
result = await Runner.run(
orchestrator,
"Review the pricing packet, then write final notes to `work/summary.md`.",
run_config=shared_run_config,
)
```
Here the parent agent runs as `coordinator`, and the explorer tool-agent runs as `explorer` inside the same live sandbox session. The `pricing_packet/` entries are readable by `other` users, so the explorer can inspect them quickly, but it does not have write bits. The `work/` directory is only available to the coordinator's user/group, so the parent can write the final artifact while the explorer stays read-only.
When a tool-agent needs real isolation instead, give it its own sandbox `RunConfig`:
```python
from docker import from_env as docker_from_env
from agents.run import RunConfig
from agents.sandbox import SandboxRunConfig
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
rollout_agent.as_tool(
tool_name="review_rollout_risk",
tool_description="Inspect the rollout packet and summarize implementation risk.",
run_config=RunConfig(
sandbox=SandboxRunConfig(
client=DockerSandboxClient(docker_from_env()),
options=DockerSandboxClientOptions(image="python:3.14-slim"),
),
),
)
```
Use a separate sandbox when the tool-agent should mutate freely, run untrusted commands, or use a different backend/image. See [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py).
### Combine with local tools and MCP
Keep the sandbox workspace while still using ordinary tools on the same agent:
```python
from agents.sandbox import SandboxAgent
from agents.sandbox.capabilities import Shell
agent = SandboxAgent(
name="Workspace reviewer",
instructions="Inspect the workspace and call host tools when needed.",
tools=[get_discount_approval_path],
mcp_servers=[server],
capabilities=[Shell()],
)
```
Use this when workspace inspection is only one part of the agent's job. See [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py).
## Memory
Use the `Memory` capability when future sandbox-agent runs should learn from prior runs. Memory is separate from the SDK's conversational `Session` memory: it distills lessons into files inside the sandbox workspace, then later runs can read those files.
See [Agent memory](memory.md) for setup, read/generate behavior, multi-turn conversations, and layout isolation.
## Composition patterns
Once the single-agent pattern is clear, the next design question is where the sandbox boundary belongs in a larger system.
Sandbox agents still compose with the rest of the SDK:
- [Handoffs](../handoffs.md): hand document-heavy work from a non-sandbox intake agent into a sandbox reviewer.
- [Agents as tools](../tools.md#agents-as-tools): expose multiple sandbox agents as tools, usually by passing `run_config=RunConfig(sandbox=SandboxRunConfig(...))` on each `Agent.as_tool(...)` call so each tool gets its own sandbox boundary.
- [MCP](../mcp.md) and normal function tools: sandbox capabilities can coexist with `mcp_servers` and ordinary Python tools.
- [Running agents](../running_agents.md): sandbox runs still use the normal `Runner` APIs.
Two patterns are especially common:
- a non-sandbox agent hands off into a sandbox agent only for the part of the workflow that needs workspace isolation
- an orchestrator exposes multiple sandbox agents as tools, usually with a separate sandbox `RunConfig` per `Agent.as_tool(...)` call so each tool gets its own isolated workspace
### Turns and sandbox runs
It helps to explain handoffs and agent-as-tool calls separately.
With a handoff, there is still one top-level run and one top-level turn loop. The active agent changes, but the run does not become nested. If a non-sandbox intake agent hands off to a sandbox reviewer, the next model call in that same run is prepared for the sandbox agent, and that sandbox agent becomes the one taking the next turn. In other words, handoffs change which agent owns the next turn of the same run. See [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py).
With `Agent.as_tool(...)`, the relationship is different. The outer orchestrator uses one outer turn to decide to call the tool, and that tool call starts a nested run for the sandbox agent. The nested run has its own turn loop, `max_turns`, approvals, and usually its own sandbox `RunConfig`. It may finish in one nested turn or take several. From the outer orchestrator's point of view, all of that work still sits behind one tool invocation, so the nested turns do not increment the outer run's turn counter. See [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py).
Approval behavior follows the same split:
- with handoffs, approvals stay on the same top-level run because the sandbox agent is now the active agent in that run
- with `Agent.as_tool(...)`, approvals raised inside the sandbox tool-agent still surface on the outer run, but they come from stored nested run state and resume the nested sandbox run when the outer run resumes
## Further reading
- [Quickstart](quickstart.md): get one sandbox agent running.
- [Sandbox clients](clients.md): choose local, Docker, hosted, and mount options.
- [Agent memory](memory.md): preserve and reuse lessons from prior sandbox runs.
- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): runnable local, coding, memory, handoff, and agent-composition patterns.
+185
View File
@@ -0,0 +1,185 @@
# Agent memory
Memory lets future sandbox-agent runs learn from prior runs. It is separate from the SDK's conversational [`Session`](../sessions/index.md) memory, which stores message history. Memory distills lessons from prior runs into files in the sandbox workspace.
!!! warning "Beta feature"
Sandbox agents are in beta. Expect details of the API, defaults, and supported capabilities to change before general availability, and expect more advanced features over time.
Memory can reduce three kinds of cost for future runs:
1. Agent cost: If the agent took a long time to complete a workflow, the next run should need less exploration. This can reduce token usage and time to completion.
2. User cost: If the user corrected the agent or expressed a preference, future runs can remember that feedback. This can reduce human intervention.
3. Context cost: If the agent completed a task before, and the user wants to build on that task, the user should not need to find the previous thread or re-type all the context. This makes task descriptions shorter.
See [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) for a complete two-run example that fixes a bug, generates memory, resumes a snapshot, and uses that memory in a follow-up verifier run. See [examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py) for a multi-turn, multi-agent example with separate memory layouts.
## Enable memory
Add `Memory()` as a capability to the sandbox agent.
```python
from pathlib import Path
import tempfile
from agents.sandbox import LocalSnapshotSpec, SandboxAgent
from agents.sandbox.capabilities import Filesystem, Memory, Shell
agent = SandboxAgent(
name="Memory-enabled reviewer",
instructions="Inspect the workspace and preserve useful lessons for follow-up runs.",
capabilities=[Memory(), Filesystem(), Shell()],
)
with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_dir:
sandbox = await client.create(
manifest=manifest,
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
)
```
If read is enabled, `Memory()` requires `Shell()`, which lets the agent read and search memory files when the injected summary is not enough. When live memory update is enabled (by default), it also requires `Filesystem()`, which lets the agent update `memories/MEMORY.md` if the agent discovers stale memory or the user asks it to update memory.
By default, memory artifacts are stored in the sandbox workspace under `memories/`. To reuse them in a later run, preserve and reuse the whole configured memories directory by keeping the same live sandbox session or resuming from a persisted session state or snapshot; a fresh empty sandbox starts with empty memory.
`Memory()` enables both reading and generating memories. Use `Memory(generate=None)` for agents that should read memory but should not generate new memories: for example, an internal agent, subagent, checker, or one-off tool agent whose run doesn't add much signal. Use `Memory(read=None)` when the run should generate memory for later, but the user doesn't want the run to be influenced by existing memory.
## Read memory
Memory reads use progressive disclosure. At the start of a run, the SDK injects a small summary (`memory_summary.md`) of generally useful tips, user preferences, and available memories into the agent's developer prompt. This gives the agent enough context to decide whether prior work may be relevant.
When prior work looks relevant, the agent searches the configured memory index (`MEMORY.md` under `memories_dir`) for keywords from the current task. It opens the corresponding prior rollout summaries under the configured `rollout_summaries/` directory only when the task needs more detail.
Memory can become stale. Agents are instructed to treat memories as guidance only and trust the current environment. By default, memory reads have `live_update` enabled, so if the agent discovers stale memory, it can update the configured `MEMORY.md` in the same run. Disable live updates when the agent should read memory but not modify it during the run, for example if the run is latency sensitive.
## Generate memory
After a run finishes, the sandbox runtime appends that run segment to a conversation file. Accumulated conversation files are processed when the sandbox session closes.
Memory generation has two phases:
1. Phase 1: conversation extraction. A memory-generating model processes one accumulated conversation file and generates a conversation summary. System, developer, and reasoning content are omitted. If the conversation is too long, it is truncated to fit within the context window, with the beginning and end preserved. It also generates a raw memory extract: compact notes from the conversation that Phase 2 can consolidate.
2. Phase 2: layout consolidation. A consolidation agent reads raw memories for one memory layout, opens conversation summaries when more evidence is needed, and extracts patterns into `MEMORY.md` and `memory_summary.md`.
The default workspace layout is:
```text
workspace/
├── sessions/
│ └── <rollout-id>.jsonl
└── memories/
├── memory_summary.md
├── MEMORY.md
├── raw_memories.md (intermediate)
├── phase_two_selection.json (intermediate)
├── raw_memories/ (intermediate)
│ └── <rollout-id>.md
├── rollout_summaries/
│ └── <rollout-id>_<slug>.md
└── skills/
```
You can configure memory generation with `MemoryGenerateConfig`:
```python
from agents.sandbox import MemoryGenerateConfig
from agents.sandbox.capabilities import Memory
memory = Memory(
generate=MemoryGenerateConfig(
max_raw_memories_for_consolidation=128,
extra_prompt="Pay extra attention to what made the customer more satisfied or annoyed",
),
)
```
Use `extra_prompt` to tell the memory generator which signals matter most for your use case, such as customer and company details for a GTM agent.
If recent raw memories exceed `max_raw_memories_for_consolidation` (defaults to 256), Phase 2 keeps only memories from the newest conversations and removes older ones. Recency is based on the last time the conversation is updated. This forgetting mechanism helps memories reflect the newest environment.
## Multi-turn conversations
For multi-turn sandbox chats, use the normal SDK `Session` together with the same live sandbox session:
```python
from agents import Runner, SQLiteSession
from agents.run import RunConfig
from agents.sandbox import SandboxRunConfig
conversation_session = SQLiteSession("gtm-q2-pipeline-review")
sandbox = await client.create(manifest=agent.default_manifest)
async with sandbox:
run_config = RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
workflow_name="GTM memory example",
)
await Runner.run(
agent,
"Analyze data/leads.csv and identify one promising GTM segment.",
session=conversation_session,
run_config=run_config,
)
await Runner.run(
agent,
"Using that analysis, write a short outreach hypothesis.",
session=conversation_session,
run_config=run_config,
)
```
Both runs append to one memory conversation file because they pass the same SDK conversation session (`session=conversation_session`) and therefore share the same `session.session_id`. This is different from the sandbox (`sandbox`), which identifies the live workspace and is not used as the memory conversation ID. Phase 1 sees the accumulated conversation when the sandbox session closes, so it can extract memory from the whole exchange instead of two isolated turns.
If you want multiple `Runner.run(...)` calls to become one memory conversation, pass a stable identifier across those calls. When memory associates a run with a conversation, it resolves in this order:
1. `conversation_id`, when you pass one to `Runner.run(...)`
2. `session.session_id`, when you pass an SDK `Session` such as `SQLiteSession`
3. `RunConfig.group_id`, when neither of the above is present
4. A generated per-run ID, when no stable identifier is present
## Use different layouts to isolate memory for different agents
Memory isolation is based on `MemoryLayoutConfig`, not on agent name. Agents with the same layout and the same memory conversation ID share one memory conversation and one consolidated memory. Agents with different layouts keep separate rollout files, raw memories, `MEMORY.md`, and `memory_summary.md`, even when they share the same sandbox workspace.
Use separate layouts when multiple agents share one sandbox but should not share memory:
```python
from agents import SQLiteSession
from agents.sandbox import MemoryLayoutConfig, SandboxAgent
from agents.sandbox.capabilities import Filesystem, Memory, Shell
gtm_agent = SandboxAgent(
name="GTM reviewer",
instructions="Analyze GTM workspace data and write concise recommendations.",
capabilities=[
Memory(
layout=MemoryLayoutConfig(
memories_dir="memories/gtm",
sessions_dir="sessions/gtm",
)
),
Filesystem(),
Shell(),
],
)
engineering_agent = SandboxAgent(
name="Engineering reviewer",
instructions="Inspect engineering workspaces and summarize fixes and risks.",
capabilities=[
Memory(
layout=MemoryLayoutConfig(
memories_dir="memories/engineering",
sessions_dir="sessions/engineering",
)
),
Filesystem(),
Shell(),
],
)
gtm_session = SQLiteSession("gtm-q2-pipeline-review")
engineering_session = SQLiteSession("eng-invoice-test-fix")
```
This prevents GTM analysis from being consolidated into engineering bug-fix memory, and vice versa.
+111
View File
@@ -0,0 +1,111 @@
# Quickstart
!!! warning "Beta feature"
Sandbox agents are in beta. Expect details of the API, defaults, and supported capabilities to change before general availability, and expect more advanced features over time.
Modern agents work best when they can operate on real files in a filesystem. **Sandbox Agents** in the Agents SDK give the model a persistent workspace where it can search large document sets, edit files, run commands, generate artifacts, and pick work back up from saved sandbox state.
The SDK gives you that execution harness without making you wire together file staging, filesystem tools, shell access, sandbox lifecycle, snapshots, and provider-specific glue yourself. You keep the normal `Agent` and `Runner` flow, then add a `Manifest` for the workspace, capabilities for sandbox-native tools, and `SandboxRunConfig` for where the work runs.
## Prerequisites
- Python 3.10 or higher
- Basic familiarity with the OpenAI Agents SDK
- A sandbox client. For local development, start with `UnixLocalSandboxClient`.
## Installation
If you have not already installed the SDK:
```bash
pip install openai-agents
```
For Docker-backed sandboxes:
```bash
pip install "openai-agents[docker]"
```
## Create a local sandbox agent
This example stages a local repo under `repo/`, loads local skills lazily, and lets the runner create a Unix-local sandbox session for the run.
```python
import asyncio
from pathlib import Path
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.capabilities import Capabilities, LocalDirLazySkillSource, Skills
from agents.sandbox.entries import LocalDir
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
EXAMPLE_DIR = Path(__file__).resolve().parent
HOST_REPO_DIR = EXAMPLE_DIR / "repo"
HOST_SKILLS_DIR = EXAMPLE_DIR / "skills"
def build_agent(model: str) -> SandboxAgent[None]:
return SandboxAgent(
name="Sandbox engineer",
model=model,
instructions=(
"Read `repo/task.md` before editing files. Stay grounded in the repository, preserve "
"existing behavior, and mention the exact verification command you ran. "
"If you edit files with apply_patch, paths are relative to the sandbox workspace root."
),
default_manifest=Manifest(
entries={
"repo": LocalDir(src=HOST_REPO_DIR),
}
),
capabilities=Capabilities.default() + [
Skills(
lazy_from=LocalDirLazySkillSource(
source=LocalDir(src=HOST_SKILLS_DIR),
)
),
],
)
async def main() -> None:
result = await Runner.run(
build_agent("gpt-5.4"),
"Open `repo/task.md`, fix the issue, run the targeted test, and summarize the change.",
run_config=RunConfig(
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
workflow_name="Sandbox coding example",
),
)
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())
```
See [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py). It uses a tiny shell-based repo so the example can be verified deterministically across Unix-local runs.
## Key choices
Once the basic run works, the choices most people reach for next are:
- `default_manifest`: the files, repos, directories, and mounts for fresh sandbox sessions
- `instructions`: short workflow rules that should apply across prompts
- `base_instructions`: an advanced escape hatch for replacing the SDK sandbox prompt
- `capabilities`: sandbox-native tools such as filesystem editing/image inspection, shell, skills, memory, and compaction
- `run_as`: the sandbox user identity for model-facing tools
- `SandboxRunConfig.client`: the sandbox backend
- `SandboxRunConfig.session`, `session_state`, or `snapshot`: how later runs reconnect to prior work
## Where to go next
- [Concepts](sandbox/guide.md): understand manifests, capabilities, permissions, snapshots, run config, and composition patterns.
- [Sandbox clients](sandbox/clients.md): choose Unix-local, Docker, hosted providers, and mount strategies.
- [Agent memory](sandbox/memory.md): preserve and reuse lessons from previous sandbox runs.
If shell access is only one occasional tool, start with hosted shell in the [tools guide](tools.md). Reach for sandbox agents when workspace isolation, sandbox client choice, or sandbox-session resume behavior are part of the design.
+33
View File
@@ -236,3 +236,36 @@
max-width: clamp(76rem, 92vw, 92rem);
}
}
.sandbox-nowrap-first-column-table th:first-child,
.sandbox-nowrap-first-column-table td:first-child {
white-space: nowrap;
width: 1%;
}
.sandbox-nowrap-first-column-table td:first-child code {
word-break: normal;
white-space: nowrap;
}
.sandbox-lifecycle-diagram {
text-align: center;
}
.sandbox-lifecycle-diagram .mermaid svg {
max-height: 20rem;
max-width: 100%;
width: auto !important;
}
.sandbox-harness-image {
text-align: center;
}
.sandbox-harness-image img {
display: block;
margin: 0 auto;
max-height: 28rem;
max-width: 100%;
width: auto;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import asyncio
import random
from typing import Any, Optional, cast
from typing import Any, cast
from pydantic import BaseModel
@@ -56,7 +56,7 @@ class ExampleHooks(RunHooks):
self,
context: RunContextWrapper,
agent: Agent,
system_prompt: Optional[str],
system_prompt: str | None,
input_items: list[TResponseInputItem],
) -> None:
self.event_counter += 1
+2 -2
View File
@@ -1,5 +1,5 @@
import asyncio
from typing import Annotated, Any, Optional
from typing import Annotated, Any
from openai.types.responses import ResponseFunctionCallArgumentsDeltaEvent
@@ -16,7 +16,7 @@ def write_file(filename: Annotated[str, "Name of the file"], content: str) -> st
def create_config(
project_name: Annotated[str, "Project name"],
version: Annotated[str, "Project version"],
dependencies: Annotated[Optional[list[str]], "Dependencies (list of packages)"],
dependencies: Annotated[list[str] | None, "Dependencies (list of packages)"],
) -> str:
"""Generate a project configuration file."""
return f"Config for {project_name} v{version} created"
+9
View File
@@ -43,6 +43,7 @@ COMMON_PATH_HINTS = (
DISCOVERY_EXCLUDE = {
"examples/run_examples.py",
"examples/sandbox/tutorials/data/dataroom/setup.py",
}
# Examples that are noisy, require extra credentials, or hang in auto runs.
@@ -161,6 +162,13 @@ def build_command_path(base_path: str | None = None) -> str:
return os.pathsep.join(dedupe_existing_paths(candidates))
def build_python_path(base_path: str | None = None) -> str:
candidates = [str(ROOT_DIR)]
if base_path:
candidates.extend(split_path_entries(base_path))
return os.pathsep.join(dedupe_existing_paths(candidates))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run example scripts sequentially.")
parser.add_argument(
@@ -450,6 +458,7 @@ def run_examples(examples: Sequence[ExampleScript], args: argparse.Namespace) ->
env = os.environ.copy()
env["PATH"] = command_path
env["PYTHONPATH"] = build_python_path(env.get("PYTHONPATH"))
if auto_mode:
env["EXAMPLES_INTERACTIVE_MODE"] = "auto"
env["APPLY_PATCH_AUTO_APPROVE"] = "1"
+59
View File
@@ -0,0 +1,59 @@
# Sandbox examples
These examples show how to run agents with an isolated workspace. Start with the
small API examples when you want the smallest surface area, or use the tutorial
scaffold when you want the shared layout for guided sandbox tutorials.
Most examples call a model through `Runner`, so set `OPENAI_API_KEY` in the
repository-root `.env` file, in the example's `.env` file when it has one, or
in your shell environment.
## Small API examples
| Example | Run | What it shows |
| --- | --- | --- |
| [`basic.py`](./basic.py) | `uv run python examples/sandbox/basic.py` | Creates a sandbox session from a manifest, runs a `SandboxAgent`, and streams the result. |
| [`handoffs.py`](./handoffs.py) | `uv run python examples/sandbox/handoffs.py` | Uses handoffs with sandbox-backed agents. |
| [`sandbox_agent_capabilities.py`](./sandbox_agent_capabilities.py) | `uv run python examples/sandbox/sandbox_agent_capabilities.py` | Configures a sandbox agent with workspace capabilities. |
| [`sandbox_agent_with_tools.py`](./sandbox_agent_with_tools.py) | `uv run python examples/sandbox/sandbox_agent_with_tools.py` | Combines sandbox capabilities with host-defined tools. |
| [`sandbox_agents_as_tools.py`](./sandbox_agents_as_tools.py) | `uv run python examples/sandbox/sandbox_agents_as_tools.py` | Exposes sandbox agents as tools for another agent. |
| [`sandbox_agent_with_remote_snapshot.py`](./sandbox_agent_with_remote_snapshot.py) | `uv run python examples/sandbox/sandbox_agent_with_remote_snapshot.py` | Starts from a remote sandbox snapshot. |
| [`memory.py`](./memory.py) | `uv run python examples/sandbox/memory.py` | Runs one sandbox agent twice across a snapshot resume so it can read and write its own memory. |
| [`memory_s3.py`](./memory_s3.py) | `source ~/.s3.env && uv run python examples/sandbox/memory_s3.py` | Runs sandbox memory across two fresh Docker sandboxes with S3-backed memory storage. |
| [`memory_multi_agent_multiturn.py`](./memory_multi_agent_multiturn.py) | `uv run python examples/sandbox/memory_multi_agent_multiturn.py` | Shows separate memory layouts for two agents sharing one sandbox workspace. |
| [`unix_local_pty.py`](./unix_local_pty.py) | `uv run python examples/sandbox/unix_local_pty.py` | Exercises an interactive pseudo-terminal in a Unix-local sandbox. |
| [`unix_local_runner.py`](./unix_local_runner.py) | `uv run python examples/sandbox/unix_local_runner.py` | Runs against the Unix-local sandbox backend directly. |
## Cloud backend examples
Cloud-provider examples live under [`extensions/`](./extensions/). They cover
E2B, Modal, and Daytona sandbox backends and require provider-specific
credentials in addition to `OPENAI_API_KEY`.
## Tutorial scaffold
[`tutorials/`](./tutorials/) contains the shared helper code, Docker image, and folder
conventions for guided sandbox tutorials. Tutorial folders are added in separate
focused changes.
## Tutorials
| Example | What it does |
| --- | --- |
| [`sandbox_resume`](./tutorials/sandbox_resume/) | Edits a workspace app and reuses a sandbox snapshot. |
| [`dataroom_qa`](./tutorials/dataroom_qa/) | Answers questions over a mounted dataroom with source-backed responses. |
| [`dataroom_metric_extract`](./tutorials/dataroom_metric_extract/) | Extracts structured financial metrics to CSV/JSONL. |
| [`repo_code_review`](./tutorials/repo_code_review/) | Reviews a sample repo and writes finding, report, and patch artifacts. |
| [`vision_website_clone`](./tutorials/vision_website_clone/) | Uses vision and a browser-review loop to clone a reference static website. |
## Workflow examples
| Example | What it does |
| --- | --- |
| [`healthcare_support`](./healthcare_support/) | Runs a synthetic healthcare support workflow with a standard orchestrator, sandbox policy agent, memory, and human approvals. |
## Shared files
- [`docker/`](./docker/) contains Docker-specific helper examples.
- [`misc/`](./misc/) contains reusable support code and tiny reference tools
used by several sandbox examples.
+1
View File
@@ -0,0 +1 @@
# Make the examples/sandbox directory a package for tooling consistency.
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
from typing import Any, Literal, cast
from openai.types.responses import ResponseTextDeltaEvent
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
from agents.sandbox.entries import File
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
Backend = Literal["docker", "modal"]
WorkspacePersistenceMode = Literal["tar", "snapshot_filesystem", "snapshot_directory"]
DEFAULT_QUESTION = "Summarize this sandbox project in 2 sentences."
DEFAULT_BACKEND: Backend = "docker"
DEFAULT_MODAL_APP_NAME = "openai-agents-python-sandbox-example"
DEFAULT_MODAL_WORKSPACE_PERSISTENCE: WorkspacePersistenceMode = "tar"
def _stream_event_banner(event_name: str) -> str | None:
if event_name == "tool_called":
return "[tool call] shell"
if event_name == "tool_output":
return "[tool output] shell"
return None
def _build_manifest(backend: Backend) -> Manifest:
backend_label = "Docker" if backend == "docker" else "Modal"
return Manifest(
entries={
"README.md": File(
content=(
b"# Demo Project\n\n"
+ (
f"This sandbox contains a tiny demo project for the {backend_label} "
"sandbox runner.\n"
).encode()
+ b"The goal is to show how Runner can prepare a sandbox workspace.\n"
)
),
"src/app.py": File(
content=b'def greet(name: str) -> str:\n return f"Hello, {name}!"\n'
),
"docs/notes.md": File(
content=(
b"# Notes\n\n"
b"- The example is intentionally minimal.\n"
b"- The model should inspect files through the shell tool.\n"
)
),
}
)
def _build_agent(*, model: str, manifest: Manifest, backend: Backend) -> SandboxAgent:
backend_label = "Docker" if backend == "docker" else "Modal"
return SandboxAgent(
name=f"{backend_label} Sandbox Assistant",
model=model,
instructions=(
"Answer questions about the sandbox workspace. Inspect the project before answering, "
"and keep the response concise. "
"Do not guess file names like package.json or pyproject.toml. "
"This demo intentionally contains a tiny workspace."
),
# `default_manifest` tells the sandbox agent which workspace it should expect.
default_manifest=manifest,
# `WorkspaceShellCapability()` exposes one shell tool so the model can inspect files.
capabilities=[WorkspaceShellCapability()],
# `tool_choice="required"` makes the demo more deterministic by forcing the model
# to look at the workspace instead of answering from prior assumptions.
model_settings=ModelSettings(tool_choice="required"),
)
def _require_modal_dependency() -> tuple[Any, Any]:
try:
from agents.extensions.sandbox import ModalSandboxClient, ModalSandboxClientOptions
except Exception as exc: # pragma: no cover - import path depends on optional extras
raise SystemExit(
"Modal-backed runs require the optional repo extra.\n"
"Install it with: uv sync --extra modal"
) from exc
return ModalSandboxClient, ModalSandboxClientOptions
def _path_resolves_to(path: str, target: Path) -> bool:
try:
return Path(path or ".").resolve() == target
except OSError:
return False
def _import_docker_from_env() -> Any:
script_dir = Path(__file__).resolve().parent
original_sys_path = sys.path[:]
try:
sys.path = [entry for entry in sys.path if not _path_resolves_to(entry, script_dir)]
from docker import from_env as docker_from_env # type: ignore[import-untyped]
except Exception as exc: # pragma: no cover - import path depends on local Docker setup
raise SystemExit(
f"Docker-backed runs failed to import the Docker SDK: {exc}\n"
"Install the repo dependencies with: make sync\n"
"If you are running this file directly, try:\n"
"uv run python -m examples.sandbox.basic --backend docker"
) from exc
finally:
sys.path = original_sys_path
return docker_from_env
def _require_docker_dependency() -> tuple[Any, Any, Any]:
docker_from_env = _import_docker_from_env()
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
return docker_from_env, DockerSandboxClient, DockerSandboxClientOptions
async def _create_session(
*,
backend: Backend,
manifest: Manifest,
agent: SandboxAgent,
):
if backend == "docker":
docker_from_env, DockerSandboxClient, DockerSandboxClientOptions = (
_require_docker_dependency()
)
client = DockerSandboxClient(docker_from_env())
sandbox = await client.create(
manifest=manifest,
options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE),
)
return client, sandbox
ModalSandboxClient, ModalSandboxClientOptions = _require_modal_dependency()
client = ModalSandboxClient()
sandbox = await client.create(
manifest=manifest,
options=ModalSandboxClientOptions(
app_name=DEFAULT_MODAL_APP_NAME,
workspace_persistence=DEFAULT_MODAL_WORKSPACE_PERSISTENCE,
),
)
return client, sandbox
async def main(
model: str,
question: str,
backend: Backend,
) -> None:
manifest = _build_manifest(backend)
agent = _build_agent(model=model, manifest=manifest, backend=backend)
client, sandbox = await _create_session(
backend=backend,
manifest=manifest,
agent=agent,
)
await sandbox.start()
print(await sandbox.ls("."))
try:
# `async with sandbox` keeps the example on the public session lifecycle API.
# `Runner` reuses the already-running session without starting it a second time.
async with sandbox:
# `Runner.run_streamed()` drives the model and yields text and tool events in real time.
result = Runner.run_streamed(
agent,
question,
run_config=RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
workflow_name=f"{backend.title()} sandbox example",
),
)
saw_text_delta = False
saw_any_text = False
# The stream contains raw text deltas from the assistant plus structured tool events.
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
banner = _stream_event_banner(event.name)
if banner is not None:
if saw_text_delta:
print()
saw_text_delta = False
print(banner)
if saw_text_delta:
print()
if not saw_any_text:
print(result.final_output)
finally:
await client.delete(sandbox)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
parser.add_argument(
"--backend",
default=DEFAULT_BACKEND,
choices=["docker", "modal"],
help="Sandbox backend to use for this example.",
)
args = parser.parse_args()
asyncio.run(
main(
args.model,
args.question,
cast(Backend, args.backend),
)
)
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
FROM ubuntu:22.04
RUN set -eux \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl wget gnupg unzip \
fuse3 libfuse3-3 nfs-common \
&& wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /etc/apt/trusted.gpg.d/microsoft.gpg \
&& set -eu; . /etc/os-release; \
case "$ID:$VERSION_CODENAME" in \
debian:trixie) ms_dist="debian/12/prod"; ms_suite="bookworm" ;; \
debian:*) ms_dist="debian/${VERSION_ID%%.*}/prod"; ms_suite="${VERSION_CODENAME:-stable}" ;; \
ubuntu:*) ms_dist="ubuntu/${VERSION_ID}/prod"; ms_suite="${VERSION_CODENAME}" ;; \
*) ms_dist="ubuntu/22.04/prod"; ms_suite="jammy" ;; \
esac; \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/trusted.gpg.d/microsoft.gpg] " \
"https://packages.microsoft.com/${ms_dist} ${ms_suite} main" \
> /etc/apt/sources.list.d/microsoft-prod.list \
&& apt-get update \
&& if ! apt-get install -y --no-install-recommends blobfuse2; then \
echo "blobfuse2 missing in distro repo; falling back to ubuntu/22.04 repo" >&2; \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/trusted.gpg.d/microsoft.gpg] " \
"https://packages.microsoft.com/ubuntu/22.04/prod jammy main" \
> /etc/apt/sources.list.d/microsoft-prod.list; \
apt-get update; \
apt-get install -y --no-install-recommends blobfuse2; \
fi \
&& arch="$(dpkg --print-architecture)" \
&& case "$arch" in \
amd64) mp_arch="x86_64" ;; \
arm64) mp_arch="arm64" ;; \
*) echo "unsupported mount-s3 arch: $arch" >&2; exit 1 ;; \
esac \
&& url="https://s3.amazonaws.com/mountpoint-s3-release/latest/${mp_arch}/mount-s3.deb" \
&& wget -O /tmp/mount-s3.deb "$url" \
&& size="$(stat -c %s /tmp/mount-s3.deb)" \
&& if [ "$size" -lt 100000 ]; then echo "download too small: $size bytes from $url" >&2; exit 1; fi \
&& apt-get install -y /tmp/mount-s3.deb || (apt-get -f install -y && apt-get install -y /tmp/mount-s3.deb) \
&& mount-s3 --version \
&& curl -fsSL https://amazon-efs-utils.aws.com/efs-utils-installer.sh | sh -s -- --install \
&& mount.s3files --version \
&& curl -fsSL https://rclone.org/install.sh | bash \
&& rclone version \
&& touch /etc/fuse.conf \
&& grep -qxF 'user_allow_other' /etc/fuse.conf || echo 'user_allow_other' >> /etc/fuse.conf \
&& rm -rf /var/lib/apt/lists/* /tmp/mount-s3.deb
+1
View File
@@ -0,0 +1 @@
# Docker-specific sandbox examples.
+165
View File
@@ -0,0 +1,165 @@
"""
Start here if you are new to Docker-backed sandbox examples.
This file keeps the flow explicit:
1. Build a manifest for the files that should appear in the sandbox workspace.
2. Create a sandbox agent that can inspect that workspace through one shell tool.
3. Start a Docker-backed sandbox session, stream the run, and print what happens.
"""
import argparse
import asyncio
import sys
from pathlib import Path
from docker import from_env as docker_from_env # type: ignore[import-untyped]
from openai.types.responses import ResponseTextDeltaEvent
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import SandboxAgent, SandboxRunConfig
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
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
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
DEFAULT_QUESTION = "Summarize this sandbox project in 2 sentences."
MAX_STREAM_TOOL_OUTPUT_CHARS = 2000
def _format_tool_arguments(raw_item: object) -> str | None:
arguments = raw_item.get("arguments") if isinstance(raw_item, dict) else None
if isinstance(arguments, str) and arguments:
return arguments
action = raw_item.get("action") if isinstance(raw_item, dict) else None
commands = action.get("commands") if isinstance(action, dict) else None
if isinstance(commands, list):
return "; ".join(command for command in commands if isinstance(command, str))
return None
def _format_tool_call(raw_item: object) -> str:
name = tool_call_name(raw_item) or "tool"
arguments = _format_tool_arguments(raw_item)
if arguments:
return f"[tool call] {name}: {arguments}"
return f"[tool call] {name}"
def _format_tool_output(output: object) -> str:
output_text = str(output)
if len(output_text) > MAX_STREAM_TOOL_OUTPUT_CHARS:
output_text = f"{output_text[:MAX_STREAM_TOOL_OUTPUT_CHARS]}..."
if output_text:
return f"[tool output]\n{output_text}"
return "[tool output]"
async def main(model: str, question: str) -> None:
# A manifest is the starting file tree for the sandbox workspace.
# Each key is a path inside the workspace and each value is the file content.
# `text_manifest()` keeps small text examples readable by hiding the bytes boilerplate.
manifest = text_manifest(
{
"README.md": (
"# Demo Project\n\n"
"This sandbox contains a tiny demo project for the sandbox runner.\n"
"The goal is to show how Runner can prepare a Docker-backed workspace.\n"
),
"src/app.py": 'def greet(name: str) -> str:\n return f"Hello, {name}!"\n',
"docs/notes.md": (
"# Notes\n\n"
"- The example is intentionally minimal.\n"
"- The model should inspect files through the shell tool.\n"
),
}
)
agent = SandboxAgent(
name="Docker Sandbox Assistant",
model=model,
instructions=(
"Answer questions about the sandbox workspace. Inspect the project before answering, "
"and keep the response concise. "
"Do not guess file names like package.json or pyproject.toml. "
"This demo intentionally contains a tiny workspace."
),
# `default_manifest` tells the sandbox agent which workspace it should expect.
default_manifest=manifest,
# `WorkspaceShellCapability()` exposes one shell tool so the model can inspect files.
capabilities=[WorkspaceShellCapability()],
# `tool_choice="required"` makes the demo more deterministic by forcing the model
# to look at the workspace instead of answering from prior assumptions.
model_settings=ModelSettings(tool_choice="required"),
)
# The Docker client owns the container lifecycle for the sandbox session.
docker_client = DockerSandboxClient(docker_from_env())
# `create()` allocates a fresh sandbox session backed by a Docker container.
# We pass the same manifest here so the container knows which files to materialize.
sandbox = await docker_client.create(
manifest=manifest,
options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE),
)
try:
# `async with sandbox` keeps the example on the public session lifecycle API.
# `Runner` reuses the already-running session without starting it a second time.
async with sandbox:
# `Runner.run_streamed()` drives the model and yields text and tool events in real time.
result = Runner.run_streamed(
agent,
question,
run_config=RunConfig(sandbox=SandboxRunConfig(session=sandbox)),
)
saw_text_delta = False
saw_any_text = False
# The stream contains raw text deltas from the assistant plus structured tool events.
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
if event.name == "tool_called" and event.item.type == "tool_call_item":
if saw_text_delta:
print()
saw_text_delta = False
print(_format_tool_call(event.item.raw_item))
elif event.name == "tool_output" and event.item.type == "tool_call_output_item":
if saw_text_delta:
print()
saw_text_delta = False
print(_format_tool_output(event.item.output))
if saw_text_delta:
print()
if not saw_any_text:
print(result.final_output)
finally:
# The client still owns deleting the underlying Docker container.
await docker_client.delete(sandbox)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
args = parser.parse_args()
asyncio.run(main(args.model, args.question))
@@ -0,0 +1 @@
# Docker mount smoke-test examples.
@@ -0,0 +1,84 @@
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
from agents.sandbox.entries import (
AzureBlobMount,
DockerVolumeMountStrategy,
FuseMountPattern,
InContainerMountStrategy,
RcloneMountPattern,
)
from examples.sandbox.docker.mounts.mount_smoke import (
MountSmokeCase,
require_env,
run_mount_smoke_test,
)
def _mount_cases() -> list[MountSmokeCase]:
account = require_env("AZURE_STORAGE_ACCOUNT")
container = require_env("AZURE_STORAGE_CONTAINER")
endpoint = os.getenv("AZURE_STORAGE_ENDPOINT")
identity_client_id = os.getenv("AZURE_CLIENT_ID")
account_key = os.getenv("AZURE_STORAGE_ACCOUNT_KEY")
return [
MountSmokeCase(
name="docker_volume/rclone",
mount_dir="azure-docker-volume-rclone",
mount=AzureBlobMount(
account=account,
container=container,
endpoint=endpoint,
identity_client_id=identity_client_id,
account_key=account_key,
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
read_only=False,
),
),
MountSmokeCase(
name="in_container/rclone",
mount_dir="azure-in-container-rclone",
mount=AzureBlobMount(
account=account,
container=container,
endpoint=endpoint,
identity_client_id=identity_client_id,
account_key=account_key,
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
read_only=False,
),
),
MountSmokeCase(
name="in_container/fuse",
mount_dir="azure-in-container-fuse",
mount=AzureBlobMount(
account=account,
container=container,
endpoint=endpoint,
identity_client_id=identity_client_id,
account_key=account_key,
mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()),
read_only=False,
),
),
]
async def main() -> None:
await run_mount_smoke_test(
provider="azure",
agent_name="Azure Blob Mount Smoke Test",
mount_cases=_mount_cases(),
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,100 @@
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
from agents.sandbox.entries import (
DockerVolumeMountStrategy,
GCSMount,
InContainerMountStrategy,
MountpointMountPattern,
RcloneMountPattern,
)
from examples.sandbox.docker.mounts.mount_smoke import (
MountSmokeCase,
require_env,
run_mount_smoke_test,
)
def _mount_cases() -> list[MountSmokeCase]:
bucket = require_env("GCS_MOUNT_BUCKET")
access_id = os.getenv("GCS_ACCESS_ID")
secret_access_key = os.getenv("GCS_SECRET_ACCESS_KEY")
prefix = os.getenv("GCS_MOUNT_PREFIX")
region = os.getenv("GCS_REGION")
endpoint_url = os.getenv("GCS_ENDPOINT_URL")
service_account_file = os.getenv("GCS_SERVICE_ACCOUNT_FILE")
service_account_credentials = os.getenv("GCS_SERVICE_ACCOUNT_CREDENTIALS")
access_token = os.getenv("GCS_ACCESS_TOKEN")
return [
MountSmokeCase(
name="docker_volume/rclone",
mount_dir="gcs-docker-volume-rclone",
mount=GCSMount(
bucket=bucket,
access_id=access_id,
secret_access_key=secret_access_key,
prefix=prefix,
region=region,
endpoint_url=endpoint_url,
service_account_file=service_account_file,
service_account_credentials=service_account_credentials,
access_token=access_token,
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
read_only=False,
),
),
MountSmokeCase(
name="in_container/rclone",
mount_dir="gcs-in-container-rclone",
mount=GCSMount(
bucket=bucket,
access_id=access_id,
secret_access_key=secret_access_key,
prefix=prefix,
region=region,
endpoint_url=endpoint_url,
service_account_file=service_account_file,
service_account_credentials=service_account_credentials,
access_token=access_token,
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
read_only=False,
),
),
MountSmokeCase(
name="in_container/mountpoint",
mount_dir="gcs-in-container-mountpoint",
mount=GCSMount(
bucket=bucket,
access_id=access_id,
secret_access_key=secret_access_key,
prefix=prefix,
region=region,
endpoint_url=endpoint_url,
service_account_file=service_account_file,
service_account_credentials=service_account_credentials,
access_token=access_token,
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
read_only=False,
),
),
]
async def main() -> None:
await run_mount_smoke_test(
provider="gcs",
agent_name="GCS Mount Smoke Test",
mount_cases=_mount_cases(),
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,153 @@
from __future__ import annotations
import os
import uuid
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
import docker # type: ignore[import-untyped]
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.entries import Mount
from agents.sandbox.errors import MountCommandError
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient,
DockerSandboxClientOptions,
)
from agents.sandbox.session.sandbox_session import SandboxSession
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
IMAGE = "agents-sandbox-docker-mount-example:latest"
DOCKERFILE = Path(__file__).resolve().parent.parent / "Dockerfile.mount"
@dataclass(frozen=True)
class MountSmokeCase:
"""One mount target to verify inside a shared Docker sandbox session."""
name: str
mount_dir: str
mount: Mount
def require_env(name: str) -> str:
"""Return a required environment variable or stop with a clear message."""
value = os.getenv(name)
if not value:
raise SystemExit(f"Missing required environment variable: {name}")
return value
def ensure_mount_image() -> None:
"""Build the Docker image with the in-container mount CLIs if it is missing."""
docker_client = docker.from_env()
try:
docker_client.images.get(IMAGE)
return
except docker.errors.ImageNotFound:
pass
print(f"building {IMAGE} from {DOCKERFILE.name}...")
docker_client.images.build(
path=str(DOCKERFILE.parent),
dockerfile=DOCKERFILE.name,
tag=IMAGE,
rm=True,
)
def build_agent(name: str, manifest: Manifest) -> SandboxAgent:
"""Create the minimal shell-only agent used by these mount smoke tests."""
return SandboxAgent(
name=name,
model=os.getenv("OPENAI_MODEL", "gpt-5.4"),
instructions=(
"Use the shell tool only. Write the requested exact content to the requested exact "
"path, read the file back with cat, and then reply with only `done`."
),
default_manifest=manifest,
capabilities=[WorkspaceShellCapability()],
model_settings=ModelSettings(tool_choice="required"),
)
async def _check_case(
sandbox: SandboxSession,
agent: SandboxAgent,
provider: str,
mount_case: MountSmokeCase,
) -> None:
key = f"docker-{provider}-mount-example-{mount_case.mount_dir}-{uuid.uuid4().hex}.txt"
path = Path("/workspace") / mount_case.mount_dir / key
expected = f"hello from {mount_case.name} {uuid.uuid4().hex}"
result = await Runner.run(
agent,
(
f"Write exactly this content to {path} with `printf %s`, not `echo`: {expected}\n"
f"Then read {path} back with cat."
),
run_config=RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
workflow_name=f"Docker {provider} mount smoke test ({mount_case.name})",
),
)
print(result.final_output)
read_back = await sandbox.read(path)
actual = read_back.read()
if not isinstance(actual, bytes):
raise TypeError(f"Expected bytes from session.read(), got {type(actual)!r}")
actual_text = actual.decode("utf-8")
if actual_text == f"{expected}\n":
actual_text = expected
assert actual_text == expected, f"read back {actual!r}, expected {expected!r}"
print(f"{mount_case.name}: ok")
async def run_mount_smoke_test(
*,
provider: str,
agent_name: str,
mount_cases: Sequence[MountSmokeCase],
) -> None:
"""Start one Docker sandbox session and verify read/write on every mount target."""
ensure_mount_image()
manifest = Manifest(
entries={mount_case.mount_dir: mount_case.mount for mount_case in mount_cases},
)
agent = build_agent(agent_name, manifest)
client = DockerSandboxClient(docker.from_env())
try:
sandbox = await client.create(
manifest=manifest,
options=DockerSandboxClientOptions(image=IMAGE),
)
except docker.errors.NotFound as exc:
if 'plugin "rclone" not found' in str(exc):
raise SystemExit("rclone Docker volume plugin not found") from exc
raise
try:
await sandbox.start()
except MountCommandError as exc:
print(f"mount command: {exc.context.get('command')}")
print(f"mount stderr: {exc.context.get('stderr')}")
raise
try:
for mount_case in mount_cases:
await _check_case(sandbox, agent, provider, mount_case)
finally:
await client.delete(sandbox)
@@ -0,0 +1,72 @@
"""Smoke-test an Amazon S3 Files file-system mount in Docker.
Required:
S3_FILES_FILE_SYSTEM_ID=fs-...
Common optional settings:
S3_FILES_MOUNT_TARGET_IP=10.0.0.123
AWS_REGION=us-east-1
S3_FILES_ACCESS_POINT=fsap-...
S3_FILES_SUBPATH=/path/in/file-system
Example:
S3_FILES_FILE_SYSTEM_ID=fs-... \
S3_FILES_MOUNT_TARGET_IP=10.0.0.123 \
AWS_REGION=us-east-1 \
uv run python examples/sandbox/docker/mounts/s3_files_mount_read_write.py
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
from agents.sandbox.entries import (
InContainerMountStrategy,
S3FilesMount,
S3FilesMountPattern,
)
from examples.sandbox.docker.mounts.mount_smoke import (
MountSmokeCase,
require_env,
run_mount_smoke_test,
)
def _mount_cases() -> list[MountSmokeCase]:
file_system_id = require_env("S3_FILES_FILE_SYSTEM_ID")
return [
MountSmokeCase(
name="in_container/s3files",
mount_dir="s3-files-in-container",
mount=S3FilesMount(
file_system_id=file_system_id,
subpath=os.getenv("S3_FILES_SUBPATH"),
mount_target_ip=os.getenv("S3_FILES_MOUNT_TARGET_IP"),
access_point=os.getenv("S3_FILES_ACCESS_POINT"),
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()),
read_only=False,
),
)
]
async def main() -> None:
await run_mount_smoke_test(
provider="s3-files",
agent_name="S3 Files Mount Smoke Test",
mount_cases=_mount_cases(),
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,85 @@
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
from agents.sandbox.entries import (
DockerVolumeMountStrategy,
InContainerMountStrategy,
MountpointMountPattern,
RcloneMountPattern,
S3Mount,
)
from examples.sandbox.docker.mounts.mount_smoke import (
MountSmokeCase,
require_env,
run_mount_smoke_test,
)
def _mount_cases() -> list[MountSmokeCase]:
bucket = require_env("S3_MOUNT_BUCKET")
return [
MountSmokeCase(
name="docker_volume/rclone",
mount_dir="s3-docker-volume-rclone",
mount=S3Mount(
bucket=bucket,
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
session_token=os.getenv("AWS_SESSION_TOKEN"),
prefix=os.getenv("S3_MOUNT_PREFIX"),
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
read_only=False,
),
),
MountSmokeCase(
name="in_container/rclone",
mount_dir="s3-in-container-rclone",
mount=S3Mount(
bucket=bucket,
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
session_token=os.getenv("AWS_SESSION_TOKEN"),
prefix=os.getenv("S3_MOUNT_PREFIX"),
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
read_only=False,
),
),
MountSmokeCase(
name="in_container/mountpoint",
mount_dir="s3-in-container-mountpoint",
mount=S3Mount(
bucket=bucket,
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
session_token=os.getenv("AWS_SESSION_TOKEN"),
prefix=os.getenv("S3_MOUNT_PREFIX"),
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
read_only=False,
),
),
]
async def main() -> None:
await run_mount_smoke_test(
provider="s3",
agent_name="S3 Mount Smoke Test",
mount_cases=_mount_cases(),
)
if __name__ == "__main__":
asyncio.run(main())
+1
View File
@@ -0,0 +1 @@
# Runnable coding-task assets for the sandbox agents docs.
+258
View File
@@ -0,0 +1,258 @@
"""Runnable sandbox coding example used by docs/sandbox_agents.md.
This example gives the model a tiny repo plus one lazy-loaded skill, then
verifies that the agent edited the repo and ran the targeted test command.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from collections.abc import Sequence
from pathlib import Path
from agents import ModelSettings, Runner
from agents.items import ToolCallItem, ToolCallOutputItem
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.capabilities import LocalDirLazySkillSource, Skills
from agents.sandbox.capabilities.capabilities import Capabilities
from agents.sandbox.entries import LocalDir
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
DEFAULT_MODEL = "gpt-5.4"
TARGET_TEST_CMD = "sh tests/test_credit_note.sh"
DEFAULT_PROMPT = (
"Open `repo/task.md`, use the `$credit-note-fixer` skill, fix the bug, run "
f"`{TARGET_TEST_CMD}`, and summarize the change."
)
EXAMPLE_DIR = Path(__file__).resolve().parent
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
def build_agent(model: str) -> SandboxAgent[None]:
return SandboxAgent(
name="Sandbox engineer",
model=model,
instructions=(
"Inspect the repo, make the smallest correct change, run the most relevant checks, "
"and summarize the file changes and risks. "
"Read `repo/task.md` before editing files. Stay grounded in the repository, preserve "
"existing behavior, and use the `$credit-note-fixer` skill before editing files. "
"When using `apply_patch`, remember that paths are relative to the sandbox workspace "
"root, not the shell working directory, so edit files as `repo/credit_note.sh` and "
"`repo/tests/test_credit_note.sh`. "
f"Run the exact verification command `{TARGET_TEST_CMD}` from `repo/`, then mention "
"that command in the final answer."
),
default_manifest=Manifest(
entries={
"repo": LocalDir(src=EXAMPLE_DIR / "repo"),
}
),
capabilities=Capabilities.default()
+ [
Skills(
lazy_from=LocalDirLazySkillSource(
source=LocalDir(src=EXAMPLE_DIR / "skills"),
)
),
],
model_settings=ModelSettings(tool_choice="required"),
)
async def _read_workspace_text(session, path: Path) -> str:
handle = await session.read(path)
try:
payload = handle.read()
finally:
handle.close()
if isinstance(payload, str):
return payload
return bytes(payload).decode("utf-8", errors="replace")
def _tool_call_name(item: ToolCallItem) -> str:
raw_item = item.raw_item
if isinstance(raw_item, dict):
raw_type = raw_item.get("type")
name = raw_item.get("name")
else:
raw_type = getattr(raw_item, "type", None)
name = getattr(raw_item, "name", None)
if raw_type == "apply_patch_call":
return "apply_patch"
if isinstance(name, str) and name:
return name
if isinstance(raw_type, str) and raw_type:
return raw_type
return ""
def _tool_call_arguments(item: ToolCallItem) -> dict[str, object]:
raw_item = item.raw_item
if isinstance(raw_item, dict):
arguments = raw_item.get("arguments")
else:
arguments = getattr(raw_item, "arguments", None)
if not isinstance(arguments, str) or arguments == "":
return {}
try:
parsed = json.loads(arguments)
except json.JSONDecodeError:
return {"_raw": arguments}
if isinstance(parsed, dict):
return parsed
return {"_value": parsed}
def _saw_target_test_command(tool_calls: list[ToolCallItem]) -> bool:
for item in tool_calls:
if _tool_call_name(item) != "exec_command":
continue
arguments = _tool_call_arguments(item)
cmd = arguments.get("cmd")
workdir = arguments.get("workdir")
if cmd == TARGET_TEST_CMD and workdir == "repo":
return True
if isinstance(cmd, str) and TARGET_TEST_CMD in cmd:
return True
if isinstance(cmd, str) and workdir == "repo" and TARGET_TEST_CMD in cmd:
return True
return False
def _tool_call_debug_lines(tool_calls: list[ToolCallItem]) -> list[str]:
lines: list[str] = []
for item in tool_calls:
lines.append(
f"{_tool_call_name(item)}: {json.dumps(_tool_call_arguments(item), sort_keys=True)}"
)
return lines
def _tool_output_debug_lines(new_items: Sequence[object]) -> list[str]:
lines: list[str] = []
for item in new_items:
if not isinstance(item, ToolCallOutputItem):
continue
output = item.output
if isinstance(output, str):
rendered = output
else:
rendered = str(output)
lines.append(rendered[:400] if len(rendered) > 400 else rendered)
return lines
def _saw_target_test_success(new_items: Sequence[object]) -> bool:
awaiting_target_output = False
for item in new_items:
if isinstance(item, ToolCallItem):
if _tool_call_name(item) != "exec_command":
awaiting_target_output = False
continue
arguments = _tool_call_arguments(item)
cmd = arguments.get("cmd")
if isinstance(cmd, str) and TARGET_TEST_CMD in cmd:
awaiting_target_output = True
continue
awaiting_target_output = False
continue
if awaiting_target_output and isinstance(item, ToolCallOutputItem):
output = item.output
if isinstance(output, str) and "2 passed" in output:
return True
awaiting_target_output = False
return False
async def main(model: str, prompt: str) -> None:
agent = build_agent(model)
client = UnixLocalSandboxClient()
sandbox = await client.create(manifest=agent.default_manifest)
try:
async with sandbox:
result = await Runner.run(
agent,
prompt,
max_turns=12,
run_config=RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
tracing_disabled=True,
workflow_name="Sandbox docs coding example",
),
)
tool_calls = [item for item in result.new_items if isinstance(item, ToolCallItem)]
tool_names = [_tool_call_name(item) for item in tool_calls]
if "load_skill" not in tool_names:
raise RuntimeError(f"Expected load_skill call, saw: {tool_names}")
if "apply_patch" not in tool_names:
raise RuntimeError(f"Expected apply_patch call, saw: {tool_names}")
if not _saw_target_test_command(tool_calls):
raise RuntimeError(
"Expected the agent to run the targeted test command.\n"
+ "\n".join(_tool_call_debug_lines(tool_calls))
)
if not _saw_target_test_success(result.new_items):
raise RuntimeError(
"Expected the targeted test command to report `2 passed`.\n"
"Tool calls:\n"
+ "\n".join(_tool_call_debug_lines(tool_calls))
+ "\nTool outputs:\n"
+ "\n".join(_tool_output_debug_lines(result.new_items))
)
verification = await sandbox.exec(
f"cd repo && {TARGET_TEST_CMD}",
shell=True,
)
verification_text = verification.stdout.decode(
"utf-8", errors="replace"
) + verification.stderr.decode("utf-8", errors="replace")
if verification.exit_code != 0 or "2 passed" not in verification_text:
raise RuntimeError(f"Post-run verification failed:\n{verification_text}")
updated_module = await _read_workspace_text(sandbox, Path("repo/credit_note.sh"))
print("=== Final summary ===")
print("final_output:", result.final_output)
print("tool_calls:", ", ".join(tool_names))
print("verification_command:", TARGET_TEST_CMD)
print("verification_result: observed target test output with `2 passed`")
print("updated_credit_note.sh:")
print(updated_module, end="" if updated_module.endswith("\n") else "\n")
finally:
await client.delete(sandbox)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Run a self-validating sandbox coding example used by the docs."
)
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="Prompt to send to the agent.")
args = parser.parse_args()
asyncio.run(main(args.model, args.prompt))
+6
View File
@@ -0,0 +1,6 @@
# Credit Note Example Repo
This tiny repo exists to support `examples/sandbox/docs/coding_task.py`.
The task is intentionally small so a sandbox coding agent can inspect the repo,
apply a minimal patch, and prove the fix with one targeted shell test command.
@@ -0,0 +1,6 @@
#!/bin/sh
customer="$1"
amount="$2"
printf 'Credit note for %s: -$%s debit.\n' "$customer" "$amount"
+15
View File
@@ -0,0 +1,15 @@
# Task
`credit_note.sh` formats a credit note incorrectly:
- It prints a debit label instead of a credit label.
- It preserves the sign instead of always showing the credited amount as positive.
Use the smallest correct fix, then run this exact verification command from the `repo/` directory:
`sh tests/test_credit_note.sh`
If you use `apply_patch`, the patch paths must still be relative to the sandbox workspace root.
That means the file paths should be `repo/credit_note.sh` and `repo/tests/test_credit_note.sh`.
Do not change the test expectations.
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
actual_positive="$(sh credit_note.sh Northwind 12.50)"
if [ "$actual_positive" != 'Credit note for Northwind: $12.50 credit.' ]; then
printf 'expected positive case to pass, got: %s\n' "$actual_positive" >&2
exit 1
fi
actual_negative="$(sh credit_note.sh Northwind -12.50)"
if [ "$actual_negative" != 'Credit note for Northwind: $12.50 credit.' ]; then
printf 'expected negative case to pass, got: %s\n' "$actual_negative" >&2
exit 1
fi
printf '2 passed\n'
@@ -0,0 +1,16 @@
---
name: credit-note-fixer
description: Fix the tiny credit-note formatting bug and rerun the exact targeted test command.
---
# Credit Note Fixer
Follow this workflow:
1. Read `repo/task.md`.
2. Inspect `repo/credit_note.sh` and `repo/tests/test_credit_note.sh`.
3. Make the smallest correct change that keeps the output label as `credit` and the amount positive.
If you use `apply_patch`, use workspace-root-relative paths such as
`repo/credit_note.sh` and `repo/tests/test_credit_note.sh`.
4. Run exactly `sh tests/test_credit_note.sh` from `repo/`.
5. In the final answer, summarize the bug, the fix, and the exact verification command.
+378
View File
@@ -0,0 +1,378 @@
# Cloud Sandbox Extension Examples
These examples are for manual verification of the cloud sandbox backends that
live under `agents.extensions.sandbox`.
They intentionally keep the flow simple:
1. Build a tiny manifest in memory.
2. Create a `SandboxAgent` that inspects that workspace through one shell tool.
3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Blaxel, or Vercel.
All of these examples require `OPENAI_API_KEY`, because they call the model through the normal
`Runner` path. Each cloud backend also needs its own provider credentials.
## E2B
### Setup
Install the repo extra:
```bash
uv sync --extra e2b
```
Create an E2B account, create an API key, and export it as `E2B_API_KEY`.
The official setup docs are:
- <https://e2b.dev/docs/api-key>
- <https://e2b.dev/docs/quickstart>
Export the required environment variables:
```bash
export OPENAI_API_KEY=...
export E2B_API_KEY=...
```
### Run
```bash
uv run python examples/sandbox/extensions/e2b_runner.py --stream
```
Useful flags:
- `--sandbox-type e2b_code_interpreter`
- `--template <template-name>`
- `--timeout 300`
- `--pause-on-exit`
The example defaults to `e2b`, which provides a bash-style interface.
Use `e2b_code_interpreter` for a Jupyter-style interface.
## Modal
If you want the same explicit session lifecycle shown in
`examples/sandbox/basic.py`, that example now accepts
`--backend modal` and reuses the same streamed tool-output flow:
```bash
uv run python examples/sandbox/basic.py \
--backend modal
```
The dedicated script below stays as the smaller extension-specific example.
### Setup
Install the repo extra:
```bash
uv sync --extra modal
```
Authenticate Modal with either CLI token setup or environment variables. The
official references are:
- <https://modal.com/docs/reference/cli/token>
- <https://modal.com/docs/reference/modal.config>
- <https://modal.com/docs/guide/sandbox>
If you want to configure credentials directly from the CLI:
```bash
uv run modal token set --token-id <token-id> --token-secret <token-secret>
```
Or export environment variables for the current shell:
```bash
export OPENAI_API_KEY=...
export MODAL_TOKEN_ID=...
export MODAL_TOKEN_SECRET=...
```
### Run
```bash
uv run python examples/sandbox/extensions/modal_runner.py \
--app-name openai-agents-python-sandbox-example \
--stream
```
Useful flags:
- `--workspace-persistence tar`
- `--workspace-persistence snapshot_filesystem`
- `--workspace-persistence snapshot_directory`
- `--sandbox-create-timeout-s 60`
- `--native-cloud-bucket-secret-name my-modal-secret`
`app_name` is required by `ModalSandboxClientOptions`, so the example makes it
an explicit CLI flag instead of hiding it.
Modal sandboxes also support native cloud bucket mounts through
`ModalCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated
`GCSMount`.
For native cloud bucket testing, you can either export raw credential
environment variables or pass `--native-cloud-bucket-secret-name` to reuse an
existing named Modal Secret instead.
## Cloudflare
### Setup
Install the repo extra:
```bash
uv sync --extra cloudflare
```
Export the required environment variables:
```bash
export OPENAI_API_KEY=...
export CLOUDFLARE_SANDBOX_WORKER_URL=...
```
If your Cloudflare Sandbox Service worker requires bearer auth, also export:
```bash
export CLOUDFLARE_SANDBOX_API_KEY=...
```
### Run
```bash
uv run python examples/sandbox/extensions/cloudflare_runner.py --stream
```
Useful flags:
- `--stream` -- stream model output to the terminal.
- `--demo pty` -- run a PTY demo (interactive Python session with `tty=true`).
- `--skip-snapshot-check` -- skip the stop/resume snapshot round-trip verification.
- `--native-cloud-bucket-name <bucket>` -- mount an R2/S3 bucket via `CloudflareBucketMountStrategy`.
- `--native-cloud-bucket-endpoint-url <url>` -- optional S3 endpoint URL.
- `--api-key <key>` -- bearer token for the worker (or set `CLOUDFLARE_SANDBOX_API_KEY`).
Cloudflare sandboxes support native cloud bucket mounts through
`CloudflareBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated
`GCSMount`.
## What to expect
Each script asks the model to inspect a small workspace and summarize it. A
successful run should:
1. Start the chosen cloud sandbox backend.
2. Materialize the manifest into the sandbox workspace.
3. Call the shell tool at least once.
4. Print either streamed text or a final short answer about the workspace.
These examples are not live-validated in CI because they depend on external
cloud credentials, but they are shaped so contributors can verify backend
behavior locally with one command per provider.
## Vercel
### Setup
Install the repo extra:
```bash
uv sync --extra vercel
```
Export the required environment variables:
```bash
export OPENAI_API_KEY=...
export VERCEL_OIDC_TOKEN=...
```
Or use explicit token and scope variables:
```bash
export OPENAI_API_KEY=...
export VERCEL_TOKEN=...
export VERCEL_PROJECT_ID=...
export VERCEL_TEAM_ID=...
```
### Run
```bash
uv run python examples/sandbox/extensions/vercel_runner.py --stream
```
Useful flags:
- `--workspace-persistence tar`
- `--workspace-persistence snapshot`
- `--runtime node22`
- `--timeout-ms 120000`
The Vercel example stays on the non-PTY path on purpose. It covers command
execution, workspace materialization, and persistence verification without
depending on interactive websocket support.
## Daytona
### Setup
Install the repo extra:
```bash
uv sync --extra daytona
```
Export the required environment variables:
```bash
export OPENAI_API_KEY=...
export DAYTONA_API_KEY=...
```
### Run
```bash
uv run python examples/sandbox/extensions/daytona/daytona_runner.py --stream
```
## Runloop
### Setup
Install the repo extra:
```bash
uv sync --extra runloop
```
Sign up for Runloop, no credit card required and $50 in credits @ [platform.runloop.ai](https://platform.runloop.ai/).
Export the required environment variables:
```bash
export OPENAI_API_KEY=...
export RUNLOOP_API_KEY=...
```
### Run
```bash
uv run python examples/sandbox/extensions/runloop/runner.py --stream
```
Useful flags:
- `--blueprint-name <name>`
- `--pause-on-exit`
- `--root`
Runloop-specific SDK features are also available directly on
`RunloopSandboxClientOptions` and `RunloopSandboxClient.platform`. Example:
```python
from agents.extensions.sandbox.runloop import (
RunloopAfterIdle,
RunloopGatewaySpec,
RunloopLaunchParameters,
RunloopMcpSpec,
RunloopSandboxClient,
RunloopSandboxClientOptions,
RunloopTunnelConfig,
)
client = RunloopSandboxClient()
sandbox = await client.create(
options=RunloopSandboxClientOptions(
blueprint_name="python-3-12",
launch_parameters=RunloopLaunchParameters(
network_policy_id="np_123",
resource_size_request="MEDIUM",
after_idle=RunloopAfterIdle(idle_time_seconds=300, on_idle="suspend"),
),
tunnel=RunloopTunnelConfig(auth_mode="authenticated"),
gateways={
"OPENAI_GATEWAY": RunloopGatewaySpec(
gateway="openai",
secret="OPENAI_GATEWAY_SECRET",
)
},
mcp={
"GITHUB_MCP": RunloopMcpSpec(
mcp_config="github-readonly",
secret="GITHUB_MCP_SECRET",
)
},
managed_secrets={"OPENAI_API_KEY": "..."},
metadata={"team": "agents"},
)
)
public_blueprints = await client.platform.blueprints.list_public()
public_benchmarks = await client.platform.benchmarks.list_public()
```
`managed_secrets` are stored as Runloop account secrets and only secret references
are persisted in session state. The platform facade also exposes Runloop-native
helpers for blueprints, benchmarks, secrets, network policies, and axons.
If you enable `--root`, Runloop launches the devbox with
`launch_parameters.user_parameters={"username":"root","uid":0}`. In that mode,
the default home and working directory become `/root`, so the example also uses
`/root` as its manifest workspace root. If you configure root launch in your
own code, either rely on that root-mode default or explicitly choose a
`manifest.root` under `/root`.
## Blaxel
### Setup
Install the repo extra:
```bash
uv sync --extra blaxel
```
Create a Blaxel account and get an API key. The official docs are:
- <https://docs.blaxel.ai>
- <https://app.blaxel.ai>
Export the required environment variables:
```bash
export OPENAI_API_KEY=...
export BL_API_KEY=...
export BL_WORKSPACE=...
```
### Run
```bash
uv run python examples/sandbox/extensions/blaxel_runner.py --stream
```
Useful flags:
- `--image blaxel/py-app`
- `--region us-pdx-1`
- `--memory 4096`
- `--ttl 1h`
- `--pause-on-exit`
- `--skip-snapshot-check`
The runner also includes standalone demos for individual features. Pass
`--demo <name>` to run one:
- `pty` -- agent-driven interactive Python session via PTY
- `drive` -- [Blaxel Drive mount](https://docs.blaxel.ai/Agent-drive/Overview) (persistent storage, requires `--drive-name`)
Blaxel sandboxes support cloud bucket mounts (S3, R2, GCS) through
`BlaxelCloudBucketMountStrategy` and persistent drive mounts through
`BlaxelDriveMountStrategy`. See the
[Blaxel Drive docs](https://docs.blaxel.ai/Agent-drive/Overview) for details.
+1
View File
@@ -0,0 +1 @@
"""Manual validation examples for cloud sandbox extensions."""
@@ -0,0 +1,466 @@
"""
Blaxel-backed sandbox example for manual validation.
This example mirrors the other cloud extension runners. It supports:
- Standard agent run (non-streaming and streaming).
- PTY interactive session demo (agent-driven).
- Blaxel Drive mount demo (persistent storage).
Prerequisites:
uv sync --extra blaxel
export OPENAI_API_KEY=...
export BL_API_KEY=...
export BL_WORKSPACE=...
Run:
# Basic agent run
uv run python examples/sandbox/extensions/blaxel_runner.py --stream
# With a specific image and region
uv run python examples/sandbox/extensions/blaxel_runner.py \\
--image blaxel/py-app --region us-pdx-1 --stream
# PTY terminal demo (agent-driven interactive Python session)
uv run python examples/sandbox/extensions/blaxel_runner.py --demo pty
# Drive mount demo (requires an existing drive, defaults region to us-was-1)
uv run python examples/sandbox/extensions/blaxel_runner.py \\
--demo drive --drive-name my-drive
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import uuid
from pathlib import Path
from openai.types.responses import ResponseTextDeltaEvent
from agents import ModelSettings, Runner, set_tracing_disabled
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.capabilities import Shell
from agents.sandbox.entries import File
from agents.sandbox.manifest import Environment
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
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
try:
from agents.extensions.sandbox import (
DEFAULT_BLAXEL_WORKSPACE_ROOT,
BlaxelDriveMountStrategy,
BlaxelSandboxClient,
BlaxelSandboxClientOptions,
)
from agents.extensions.sandbox.blaxel import BlaxelDriveMount
except Exception as exc:
raise SystemExit(
"Blaxel sandbox examples require the optional repo extra.\n"
"Install it with: uv sync --extra blaxel"
) from exc
DEFAULT_MODEL = "gpt-5.4"
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."
)
def _build_manifest() -> Manifest:
"""Build a small demo manifest for the default agent run."""
manifest = text_manifest(
{
"README.md": (
"# Blaxel Demo Workspace\n\nThis workspace validates the Blaxel sandbox backend.\n"
),
"project/status.md": (
"# Project Status\n\n"
"- Backend: Blaxel cloud sandbox\n"
"- Region: auto-selected\n"
"- Features: exec, file I/O, PTY, drives, preview URLs\n"
),
"project/tasks.md": (
"# Tasks\n\n"
"1. Inspect the workspace files.\n"
"2. List all features mentioned in status.md.\n"
"3. Summarize in 2-3 sentences.\n"
),
}
)
return Manifest(
root=DEFAULT_BLAXEL_WORKSPACE_ROOT,
entries=manifest.entries,
environment=Environment(
value={"DEMO_ENV": "blaxel-agent-demo"},
),
)
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.")
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
# ---------------------------------------------------------------------------
# PTY demo (agent-driven)
# ---------------------------------------------------------------------------
async def _run_pty_demo(
*,
model: str,
question: str,
image: str | None,
region: str | None,
) -> None:
"""Demonstrate PTY interaction: start an interactive Python process and continue it."""
agent = SandboxAgent(
name="Blaxel 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=Manifest(
root=DEFAULT_BLAXEL_WORKSPACE_ROOT,
entries=text_manifest(
{
"README.md": (
"# Blaxel PTY Agent Example\n\n"
"This workspace is used by the Blaxel PTY demo.\n"
),
}
).entries,
),
capabilities=[Shell()],
model_settings=ModelSettings(tool_choice="required"),
)
client = BlaxelSandboxClient()
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=client,
options=BlaxelSandboxClientOptions(
name=f"blaxel-demo-pty-{uuid.uuid4().hex[:8]}",
image=image,
region=region,
),
),
workflow_name="Blaxel PTY sandbox example",
)
try:
result = Runner.run_streamed(agent, question, run_config=run_config)
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.close()
# ---------------------------------------------------------------------------
# Drive demo
# ---------------------------------------------------------------------------
async def _run_drive_demo(
*,
model: str,
question: str | None,
image: str | None,
region: str | None,
drive_name: str | None,
stream: bool,
) -> None:
"""Mount a Blaxel Drive and write a file to it."""
if not drive_name:
print("Usage: --demo drive --drive-name <name>")
print()
print("You need an existing Blaxel Drive. Create one at:")
print(" https://app.blaxel.ai or via the Blaxel CLI.")
return
# Blaxel drives must be in the same region as the sandbox.
effective_region = region or os.environ.get("BL_REGION") or "us-was-1"
mount_path = "/mnt/demo-drive"
manifest = Manifest(
root=DEFAULT_BLAXEL_WORKSPACE_ROOT,
entries={
"README.md": File(
content=(b"# Blaxel Drive Demo\n\nThe drive is mounted at /mnt/demo-drive.\n")
),
"drive": BlaxelDriveMount(
drive_name=drive_name,
drive_mount_path=mount_path,
mount_strategy=BlaxelDriveMountStrategy(),
),
},
)
marker = f"demo-{uuid.uuid4().hex[:8]}"
agent = SandboxAgent(
name="Blaxel Drive Demo",
model=model,
instructions=(
"Execute the exact shell commands the user gives you. "
"Do not explore, do not run any other commands. "
"Report the stdout and stderr of each command you ran. "
"You must run the exact commands from the user message using the shell tool. "
"Do not substitute, rewrite, or add any commands. Just execute and report output."
),
default_manifest=manifest,
capabilities=[Shell()],
model_settings=ModelSettings(tool_choice="required"),
)
client = BlaxelSandboxClient()
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=client,
options=BlaxelSandboxClientOptions(
name=f"blaxel-demo-drive-{uuid.uuid4().hex[:8]}",
image=image,
region=effective_region,
),
),
workflow_name="Blaxel drive demo",
)
effective_question = question or (
f"Run: echo 'drive persistence ok ({marker})' > {mount_path}/{marker}.txt && "
f"cat {mount_path}/{marker}.txt && ls {mount_path}"
)
if not stream:
result = await Runner.run(agent, effective_question, run_config=run_config)
print(result.final_output)
else:
stream_result = Runner.run_streamed(agent, effective_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()
await client.close()
# ---------------------------------------------------------------------------
# Standard agent run (streaming / non-streaming)
# ---------------------------------------------------------------------------
async def main(
*,
model: str,
question: str | None,
image: str | None,
region: str | None,
memory: int | None,
ttl: str | None,
pause_on_exit: bool,
stream: bool,
demo: str | None,
drive_name: str | None,
) -> None:
_require_env("OPENAI_API_KEY")
# Handle dedicated demos.
if demo == "pty":
await _run_pty_demo(
model=model,
question=question or DEFAULT_PTY_QUESTION,
image=image,
region=region,
)
return
if demo == "drive":
await _run_drive_demo(
model=model,
question=question,
image=image,
region=region,
drive_name=drive_name,
stream=stream,
)
return
manifest = _build_manifest()
agent = SandboxAgent(
name="Blaxel 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. Also run `echo $DEMO_ENV` to confirm environment "
"variables are set."
),
default_manifest=manifest,
capabilities=[WorkspaceShellCapability()],
model_settings=ModelSettings(tool_choice="required"),
)
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=BlaxelSandboxClient(),
options=BlaxelSandboxClientOptions(
name=f"blaxel-demo-agent-{uuid.uuid4().hex[:8]}",
image=image,
region=region,
memory=memory,
ttl=ttl,
labels={"purpose": "agent-demo", "source": "blaxel-runner"},
pause_on_exit=pause_on_exit,
),
),
workflow_name="Blaxel sandbox example",
)
effective_question = question or DEFAULT_QUESTION
if not stream:
result = await Runner.run(agent, effective_question, run_config=run_config)
print(result.final_output)
return
stream_result = Runner.run_streamed(agent, effective_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="Blaxel sandbox demo -- showcases sandbox features.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"demos:\n"
" agent Run a sandboxed agent (default)\n"
" pty Agent-driven PTY interactive terminal\n"
" drive Mount a Blaxel Drive (requires --drive-name)\n"
),
)
parser.add_argument(
"--demo",
choices=["agent", "pty", "drive"],
default="agent",
help="Which demo to run (default: agent).",
)
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name.")
parser.add_argument("--question", default=None, help="Override the default prompt.")
parser.add_argument("--stream", action="store_true", help="Stream response.")
parser.add_argument("--image", default=None, help="Sandbox image.")
parser.add_argument("--region", default=None, help="Sandbox region.")
parser.add_argument("--memory", type=int, default=None, help="Memory in MB.")
parser.add_argument("--ttl", default=None, help="Sandbox TTL (e.g. '1h').")
parser.add_argument("--pause-on-exit", action="store_true", help="Pause on exit.")
parser.add_argument("--drive-name", default=None, help="Drive name for drive demo.")
args = parser.parse_args()
asyncio.run(
main(
model=args.model,
question=args.question,
image=args.image,
region=args.region,
memory=args.memory,
ttl=args.ttl,
pause_on_exit=args.pause_on_exit,
stream=args.stream,
demo=args.demo,
drive_name=args.drive_name,
)
)
@@ -0,0 +1,446 @@
"""
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.4"
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,
)
)
@@ -0,0 +1 @@
"""Daytona sandbox extension examples."""
@@ -0,0 +1,208 @@
"""
Minimal Daytona-backed sandbox example for manual validation.
This mirrors the E2B and Modal extension examples: it creates a tiny workspace,
asks a sandboxed agent to inspect it through one shell tool, and prints a short
answer.
"""
import argparse
import asyncio
import os
import sys
from pathlib import Path
from openai.types.responses import ResponseTextDeltaEvent
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.entries import S3Mount
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
from examples.sandbox.misc.example_support import text_manifest
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
try:
from agents.extensions.sandbox import (
DEFAULT_DAYTONA_WORKSPACE_ROOT,
DaytonaCloudBucketMountStrategy,
DaytonaSandboxClient,
DaytonaSandboxClientOptions,
)
except Exception as exc: # pragma: no cover - import path depends on optional extras
raise SystemExit(
"Daytona sandbox examples require the optional repo extra.\n"
"Install it with: uv sync --extra daytona"
) from exc
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
def _build_manifest(
*,
cloud_bucket_name: str | None = None,
cloud_bucket_mount_path: str | None = None,
cloud_bucket_endpoint_url: str | None = None,
cloud_bucket_key_prefix: str | None = None,
) -> Manifest:
"""Build a small demo manifest, optionally including a cloud bucket mount."""
manifest = text_manifest(
{
"README.md": (
"# Daytona Demo Workspace\n\n"
"This workspace exists to validate the Daytona sandbox backend manually.\n"
),
"launch.md": (
"# Launch\n\n"
"- Customer: Contoso Logistics.\n"
"- Goal: validate the remote sandbox agent path.\n"
"- Current status: Daytona backend smoke and app-server connectivity are passing.\n"
),
"tasks.md": (
"# Tasks\n\n"
"1. Inspect the workspace files.\n"
"2. Summarize the setup and any notable status in two sentences.\n"
),
}
)
if cloud_bucket_name is None:
return Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT, entries=manifest.entries)
manifest.entries["cloud-bucket"] = S3Mount(
bucket=cloud_bucket_name,
access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
session_token=os.environ.get("AWS_SESSION_TOKEN"),
endpoint_url=cloud_bucket_endpoint_url,
prefix=cloud_bucket_key_prefix,
mount_path=Path(cloud_bucket_mount_path) if cloud_bucket_mount_path is not None else None,
read_only=False,
mount_strategy=DaytonaCloudBucketMountStrategy(),
)
return Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT, entries=manifest.entries)
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 main(
*,
model: str,
question: str,
pause_on_exit: bool,
stream: bool,
cloud_bucket_name: str | None = None,
cloud_bucket_mount_path: str | None = None,
cloud_bucket_endpoint_url: str | None = None,
cloud_bucket_key_prefix: str | None = None,
) -> None:
_require_env("OPENAI_API_KEY")
_require_env("DAYTONA_API_KEY")
manifest = _build_manifest(
cloud_bucket_name=cloud_bucket_name,
cloud_bucket_mount_path=cloud_bucket_mount_path,
cloud_bucket_endpoint_url=cloud_bucket_endpoint_url,
cloud_bucket_key_prefix=cloud_bucket_key_prefix,
)
agent = SandboxAgent(
name="Daytona 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 = DaytonaSandboxClient()
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=client,
options=DaytonaSandboxClientOptions(pause_on_exit=pause_on_exit),
),
workflow_name="Daytona sandbox example",
)
try:
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.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
parser.add_argument(
"--pause-on-exit",
action="store_true",
default=False,
help="Pause the Daytona sandbox on shutdown instead of deleting it.",
)
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
parser.add_argument(
"--cloud-bucket-name",
default=None,
help="S3 bucket name to mount into the sandbox.",
)
parser.add_argument(
"--cloud-bucket-mount-path",
default=None,
help=(
"Mount path for --cloud-bucket-name. Relative paths are resolved under the "
"workspace root. Defaults to the mount class default."
),
)
parser.add_argument(
"--cloud-bucket-endpoint-url",
default=None,
help="Optional endpoint URL for --cloud-bucket-name (S3 only, e.g. MinIO).",
)
parser.add_argument(
"--cloud-bucket-key-prefix",
default=None,
help="Optional key prefix for --cloud-bucket-name.",
)
args = parser.parse_args()
asyncio.run(
main(
model=args.model,
question=args.question,
pause_on_exit=args.pause_on_exit,
stream=args.stream,
cloud_bucket_name=args.cloud_bucket_name,
cloud_bucket_mount_path=args.cloud_bucket_mount_path,
cloud_bucket_endpoint_url=args.cloud_bucket_endpoint_url,
cloud_bucket_key_prefix=args.cloud_bucket_key_prefix,
)
)
@@ -0,0 +1,97 @@
# NASA Spending Text-to-SQL Agent
Multi-turn conversational agent that translates natural-language questions about NASA federal
spending into SQL queries, executes them against a local SQLite database, and returns structured
tabular results.
## How it works
1. **Schema knowledge**: The agent receives a compact schema summary in its system prompt and can
read detailed per-table documentation from workspace files on demand.
2. **SQL execution**: A custom `SqlCapability` provides a `run_sql` tool with guardrails — read-only
mode, statement validation, row limits, and query timeouts. The agent is instructed to use
`run_sql` for all queries; the tool enforces read-only access at the SQLite level.
3. **Multi-turn conversation**: The agent retains context across turns, so you can ask follow-up
questions like "break that down by year" or "just the top 5".
4. **Compaction**: Uses the `Compaction` capability to automatically summarize older conversation
context, keeping long sessions within the model's context window.
5. **Pause/resume**: Type `exit` to pause the sandbox and quit. Run the script again to reconnect
to the same paused sandbox — no re-download needed. If the sandbox can't be reconnected (e.g.
it was deleted or expired), a fresh one is created and the database is rebuilt automatically.
6. **Memory**: Uses the `Memory` capability to extract learnings from each conversation and
consolidate them into structured files. On subsequent sessions, the agent starts with context
from previous conversations (useful query patterns, data caveats, etc.).
## Data
The database contains NASA federal spending data from [USAspending.gov](https://usaspending.gov),
defaulting to FY2021-FY2025 (configurable via `--start-fy`/`--end-fy` flags on `setup_db.py`).
It uses a single `spending` table where each row is one transaction (obligation, modification,
or de-obligation) on a federal award. The agent aggregates as needed via SQL.
The database is built automatically on first run (requires internet access in the sandbox).
Subsequent runs reuse the existing database.
## Prerequisites
- Python 3.12+
- `openai-agents` installed with Daytona support (`uv sync --extra daytona` from repo root)
- `OPENAI_API_KEY` environment variable set (for the LLM)
- `DAYTONA_API_KEY` environment variable set (for the sandbox — get one at [daytona.io](https://daytona.io))
- Internet access (for first-run database setup inside the sandbox)
## Run
From the repository root:
```bash
export OPENAI_API_KEY="sk-..."
export DAYTONA_API_KEY="..."
uv run python -m examples.sandbox.extensions.daytona.usaspending_text2sql.agent
```
## Example questions
```
> What are NASA's top 10 contractors by total spending?
> Break that down by fiscal year
> Which NASA centers award the most contracts?
> Show me grants to universities in California
> How has NASA spending changed over time?
> What are the largest individual awards in the last 3 years?
> Compare contract vs grant spending by year
```
## Architecture
```
daytona/usaspending_text2sql/
├── agent.py — SandboxAgent definition + interactive REPL
├── sql_capability.py — SqlCapability (Capability) with run_sql tool and guardrails
├── setup_db.py — Runs inside sandbox; fetches data from USAspending API, builds SQLite DB
├── schema/
│ ├── overview.md — Compact schema summary (injected into instructions)
│ └── tables/ — Per-table column documentation (read on demand via Shell capability)
└── README.md
```
### SQL guardrails (defense in depth)
1. **Connection-level**: SQLite opened with `?mode=ro` URI (read-only)
2. **PRAGMA**: `query_only = ON` prevents writes even if validation is bypassed
3. **Statement validation**: Only `SELECT`, `WITH`, `EXPLAIN`, `PRAGMA` are allowed
4. **Row limit**: Hard cap (default 100 rows) with truncation detection
5. **Timeout**: Queries killed after 30 seconds
### Audit log
All sandbox operations (exec calls, start/stop, SQL queries and their results) are logged to
`.audit_log.jsonl` as structured JSONL events via the SDK's `Instrumentation` and `JsonlOutboxSink`.
This is useful for debugging, replaying sessions, or inspecting exactly what SQL the agent ran.
### Sandbox
This example uses Daytona as its sandbox backend. The agent and capability definitions are
backend-agnostic, but the entrypoint (`agent.py`) hardcodes `DaytonaSandboxClient` and
Daytona-specific features like pause/resume.
@@ -0,0 +1 @@
"""USAspending text-to-SQL Daytona sandbox example."""
@@ -0,0 +1,504 @@
"""NASA spending text-to-SQL agent.
Multi-turn conversational agent that translates natural-language questions
about NASA federal spending into SQL queries, executes them against a
USAspending SQLite database, and returns structured results.
Usage:
uv run python -m examples.sandbox.extensions.daytona.usaspending_text2sql.agent
The database is built automatically inside the sandbox on first run by
executing setup_db.py (requires internet access). Subsequent runs reuse the
existing database.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import sys
import textwrap
from pathlib import Path
from typing import Any
from openai.types.responses import ResponseTextDeltaEvent
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.capabilities.compaction import Compaction
from agents.sandbox.capabilities.memory import Memory
from agents.sandbox.capabilities.shell import Shell
from agents.sandbox.config import MemoryGenerateConfig, MemoryReadConfig
from agents.sandbox.entries import Dir, File, LocalDir, LocalFile
from agents.sandbox.session import (
EventPayloadPolicy,
Instrumentation,
JsonlOutboxSink,
)
from examples.sandbox.extensions.daytona.usaspending_text2sql.sql_capability import (
SqlCapability,
)
try:
from agents.extensions.sandbox import (
DEFAULT_DAYTONA_WORKSPACE_ROOT,
DaytonaSandboxClient,
DaytonaSandboxClientOptions,
DaytonaSandboxSessionState,
)
except Exception as exc: # pragma: no cover
raise SystemExit(
"Daytona sandbox examples require the optional repo extra.\n"
"Install it with: uv sync --extra daytona"
) from exc
EXAMPLE_DIR = Path(__file__).parent
SCHEMA_DIR = EXAMPLE_DIR / "schema"
SETUP_DB_PATH = EXAMPLE_DIR / "setup_db.py"
SESSION_STATE_PATH = EXAMPLE_DIR / ".session_state.json"
AUDIT_LOG_PATH = EXAMPLE_DIR / ".audit_log.jsonl"
# Set at runtime once the exposed port is resolved.
_downloads_base_url: str = ""
DEVELOPER_INSTRUCTIONS = (
(SCHEMA_DIR / "overview.md").read_text()
+ """
## Instructions
- Always use the `run_sql` tool to query the database. Never attempt to run sqlite3 directly.
- Read schema documentation from schema/tables/ if you need detailed column information.
- Read schema/glossary.md for official USAspending term definitions (e.g., what "obligation" vs "outlay" means).
- Prefer aggregations (GROUP BY, SUM, COUNT, AVG) over returning many raw rows.
- Format monetary values with dollar signs and commas in your final answers (e.g., $1,234,567).
- When the user asks a follow-up question, use conversation context to understand references
like "break that down by year" or "just the top 5".
- If a query fails, read the error message and try to fix the SQL.
- Explain your query logic briefly so the user can verify correctness.
## Data caveats
- The database contains **obligations** (money legally committed), not outlays (money actually paid).
When the user asks about "spending", clarify that these are obligation amounts.
- Amounts are tied to the **action_date** (when the obligation was signed), not when the work happens.
A multi-year contract may appear entirely in the fiscal year it was obligated.
- Some recipients are masked as "MULTIPLE RECIPIENTS" or "REDACTED DUE TO PII" for privacy reasons.
Mention this if recipient-level analysis looks incomplete.
"""
)
DB_PATH = "data/usaspending.db"
WORKSPACE_ROOT = DEFAULT_DAYTONA_WORKSPACE_ROOT
def build_agent() -> SandboxAgent:
"""Build the agent blueprint."""
manifest = Manifest(
root=WORKSPACE_ROOT,
entries={
"setup_db.py": LocalFile(src=SETUP_DB_PATH),
"schema": LocalDir(src=SCHEMA_DIR),
"data": Dir(ephemeral=True),
"memory/memory_summary.md": File(content=b""),
"memory/phase_two_selection.json": File(content=b""),
},
)
return SandboxAgent(
name="NASA Spending Q&A",
default_manifest=manifest,
model="gpt-5.4",
instructions=(
"You are a helpful data analyst that answers questions about NASA federal spending "
"by writing and executing SQL queries.\n\n" + DEVELOPER_INSTRUCTIONS
),
capabilities=[
SqlCapability(db_path=DB_PATH),
Shell(),
Compaction(),
Memory(
read=MemoryReadConfig(live_update=False),
generate=MemoryGenerateConfig(
extra_prompt=(
"Pay attention to which SQL patterns work best for the USAspending data, "
"column quirks (e.g. recipient_parent_name vs recipient_name for grouping), "
"and data caveats the user discovers (e.g. negative obligations, masked "
"recipients)."
),
),
),
],
)
# ---------------------------------------------------------------------------
# Terminal formatting helpers (unchanged from universal_computer version)
# ---------------------------------------------------------------------------
DIM = "\033[2;39m"
DIM_CYAN = "\033[2;36m"
DIM_BLUE = "\033[2;34m"
DIM_YELLOW = "\033[2;33m"
DIM_GREEN = "\033[2;32m"
RESET = "\033[0m"
_SQL_KEYWORDS = (
r"\b(?:SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|CROSS|FULL|NATURAL|ON|AND|OR"
r"|NOT|IN|IS|NULL|AS|WITH|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|UNION"
r"|ALL|DISTINCT|CASE|WHEN|THEN|ELSE|END|EXISTS|BETWEEN|LIKE|INSERT|UPDATE"
r"|DELETE|CREATE|DROP|ALTER|SET|VALUES|INTO|TABLE|INDEX|VIEW|ASC|DESC|BY"
r"|OVER|PARTITION\s+BY)\b"
)
_SQL_FUNCTIONS = (
r"\b(?:COUNT|SUM|AVG|MIN|MAX|COALESCE|CAST|SUBSTR|LENGTH|ROUND|ABS|IFNULL"
r"|NULLIF|REPLACE|TRIM|UPPER|LOWER|DATE|DATETIME|STRFTIME|TYPEOF|TOTAL"
r"|GROUP_CONCAT|PRINTF|ROW_NUMBER|RANK|DENSE_RANK)(?=\s*\()"
)
_SQL_STRING = r"'(?:''|[^'])*'"
def _highlight_sql(sql: str) -> str:
"""Apply ANSI syntax highlighting to a SQL string."""
placeholders: list[str] = []
def _stash_string(m: re.Match[str]) -> str:
placeholders.append(m.group(0))
return f"\x00STR{len(placeholders) - 1}\x00"
result = re.sub(_SQL_STRING, _stash_string, sql)
result = re.sub(
_SQL_KEYWORDS,
lambda m: f"{DIM_BLUE}{m.group(0)}{DIM}",
result,
flags=re.IGNORECASE,
)
result = re.sub(
_SQL_FUNCTIONS,
lambda m: f"{DIM_YELLOW}{m.group(0)}{DIM}",
result,
flags=re.IGNORECASE,
)
def _restore_string(m: re.Match[str]) -> str:
idx = int(m.group(1))
return f"{DIM_GREEN}{placeholders[idx]}{DIM}"
result = re.sub(r"\x00STR(\d+)\x00", _restore_string, result)
return result
def _format_tool_args(name: str, arguments: str) -> str:
"""Format a tool call for display, pretty-printing SQL queries."""
if name == "run_sql":
try:
args = json.loads(arguments)
query = args.get("query", "")
limit = args.get("limit")
header = f" {DIM}[SQL]"
if limit is not None:
header += f" (limit {limit})"
header += RESET
highlighted = _highlight_sql(query)
sql = textwrap.indent(highlighted, " ")
return f"{header}\n{DIM}{sql}{RESET}"
except Exception:
pass
return f" {DIM}[tool] {name}({arguments}){RESET}"
def _format_tool_result(output: str) -> str | None:
"""Format a tool result for display. Returns None for non-SQL results."""
try:
data = json.loads(output)
except (json.JSONDecodeError, TypeError):
if output.strip():
return f" {DIM}{output.strip()}{RESET}"
return None
columns = data.get("columns")
rows = data.get("rows")
if not isinstance(columns, list) or not isinstance(rows, list):
return None
row_count = data.get("row_count", len(rows))
display_count = data.get("display_count", len(rows))
truncated = data.get("truncated", False)
if not columns:
return f" {DIM_CYAN}\u2192 Result (0 rows){RESET}"
# Build the summary line.
parts = []
if display_count < row_count:
parts.append(f"showing {display_count} of {row_count}")
else:
parts.append(f"{row_count} rows")
if truncated:
parts.append("CSV truncated at limit")
csv_file = data.get("csv_file")
download_line = ""
if csv_file and _downloads_base_url:
download_line = f"\n {DIM}\u2193 {_downloads_base_url}{csv_file}{RESET}"
# Try to fit the table in the terminal. If too wide, skip it —
# the model's prose summary + download link are enough.
try:
term_width = os.get_terminal_size().columns
except OSError:
term_width = 120
widths = [len(str(c)) for c in columns]
for row in rows:
for i, val in enumerate(row):
widths[i] = max(widths[i], len(str(val) if val is not None else "NULL"))
# 4 leading spaces + "| " between each col + trailing " |"
table_width = 4 + sum(widths) + 3 * len(widths) + 1
if table_width > term_width:
header = f" {DIM_CYAN}\u2192 Result ({row_count} rows) \u2014 too wide to print in terminal, download below{RESET}"
return f"{header}{download_line}"
def fmt_row(vals: list[Any]) -> str:
cells = []
for v, w in zip(vals, widths, strict=False):
cells.append(str(v if v is not None else "NULL").ljust(w))
return " | " + " | ".join(cells) + " |"
lines = [fmt_row(columns)]
lines.append(" |" + "|".join("-" * (w + 2) for w in widths) + "|")
for row in rows:
lines.append(fmt_row(row))
header = f" {DIM_CYAN}\u2192 Result ({', '.join(parts)})"
table = "\n".join(lines)
return f"{header}\n{table}{RESET}{download_line}"
# ---------------------------------------------------------------------------
# Multi-turn REPL using Runner.run_streamed()
# ---------------------------------------------------------------------------
async def run_turn(
agent: SandboxAgent,
conversation: list[Any],
question: str,
run_config: RunConfig,
) -> list[Any]:
"""Run one conversational turn and return the updated conversation history."""
input_items = conversation + [{"role": "user", "content": question}]
result = Runner.run_streamed(agent, input_items, run_config=run_config)
async for event in result.stream_events():
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
print(event.data.delta, end="", flush=True)
continue
if event.type != "run_item_stream_event":
continue
if event.name == "tool_called":
item = event.item
raw = getattr(item, "raw_item", None)
if raw is not None:
name = getattr(raw, "name", "")
arguments = getattr(raw, "arguments", "")
print()
print(_format_tool_args(name, arguments))
continue
if event.name == "tool_output":
item = event.item
output = getattr(item, "output", "")
if isinstance(output, str):
formatted = _format_tool_result(output)
if formatted is not None:
print(formatted)
print()
continue
print()
# Build the full conversation history for the next turn using the SDK's
# built-in conversion, which correctly serializes all item types.
return result.to_input_list()
# ---------------------------------------------------------------------------
# Session state persistence for pause/resume
# ---------------------------------------------------------------------------
def _load_session_state() -> DaytonaSandboxSessionState | None:
"""Load saved session state from disk, or return None."""
if not SESSION_STATE_PATH.exists():
return None
try:
return DaytonaSandboxSessionState.model_validate_json(SESSION_STATE_PATH.read_text())
except Exception:
return None
def _save_session_state(state: DaytonaSandboxSessionState) -> None:
"""Persist session state to disk so the sandbox can be reused next run."""
SESSION_STATE_PATH.write_text(state.model_dump_json(indent=2))
# ---------------------------------------------------------------------------
# Main entrypoint
# ---------------------------------------------------------------------------
async def main() -> None:
agent = build_agent()
instrumentation = Instrumentation(
sinks=[JsonlOutboxSink(AUDIT_LOG_PATH)],
payload_policy=EventPayloadPolicy(include_exec_output=True),
)
RESULTS_PORT = 8080
client = DaytonaSandboxClient(instrumentation=instrumentation)
client_options = DaytonaSandboxClientOptions(
pause_on_exit=True,
exposed_ports=(RESULTS_PORT,),
)
# Try to resume a previously paused sandbox.
saved_state = _load_session_state()
sandbox = None
destroy = False
try:
if saved_state is not None:
old_sandbox_id = saved_state.sandbox_id
try:
sandbox = await client.resume(saved_state)
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
if sandbox.state.sandbox_id == old_sandbox_id:
print("Reconnected to existing sandbox.")
else:
print("Previous sandbox no longer exists. Created a new one.")
except Exception as e:
print(f"Could not resume previous sandbox: {e}")
saved_state = None
sandbox = None
if sandbox is None:
sandbox = await client.create(manifest=agent.default_manifest, options=client_options)
await sandbox.start()
# Persist state immediately so crashes don't orphan the sandbox.
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
_save_session_state(sandbox.state)
# Build database inside sandbox (idempotent — skips if DB already exists).
print("Setting up database (may take a few minutes on first run)...")
result = await sandbox.exec("python3", "setup_db.py", timeout=1800.0)
stdout = result.stdout.decode("utf-8", errors="replace")
if stdout.strip():
print(stdout)
if not result.ok():
stderr = result.stderr.decode("utf-8", errors="replace")
print(f"Database setup failed:\n{stderr}", file=sys.stderr)
sys.exit(1)
# Start a file server in the sandbox so query results can be downloaded.
await sandbox.exec("mkdir -p results", timeout=5.0)
await sandbox.exec(
f"nohup python3 -m http.server {RESULTS_PORT} --directory results > /dev/null 2>&1 &",
timeout=5.0,
)
# Resolve the Daytona signed URL for the file server.
global _downloads_base_url
try:
endpoint = await sandbox.resolve_exposed_port(RESULTS_PORT)
_downloads_base_url = endpoint.url_for("http")
except Exception as e:
print(f" Warning: could not resolve download URL: {e}")
run_config = RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
workflow_name="NASA Spending Q&A",
)
downloads_line = ""
if _downloads_base_url:
downloads_line = f"\n Browse results: {DIM_CYAN}{_downloads_base_url}{RESET}"
print(f"""
{DIM}{"=" * 60}{RESET}
NASA Spending Q&A (FY2021\u2013FY2025)
Data from USAspending.gov \u2014 contracts, grants, and IDVs
awarded by NASA. Each row is a transaction (obligation).
Includes: amounts, award descriptions, recipients, recipient
locations, places of performance, industry and product
categories, sub-agencies, and fiscal years.
{downloads_line}
Type {DIM_CYAN}'exit'{RESET} to pause sandbox, {DIM_CYAN}'destroy'{RESET} to delete it.
{DIM}{"=" * 60}{RESET}
""")
conversation: list[Any] = []
while True:
try:
question = input("> ")
except (EOFError, KeyboardInterrupt):
print()
break
cmd = question.strip().lower()
if cmd == "exit":
break
if cmd == "destroy":
destroy = True
break
if not question.strip():
continue
try:
conversation = await run_turn(agent, conversation, question, run_config)
except Exception as e:
print(f"\nError: {e}")
print()
if destroy:
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
sandbox.state.pause_on_exit = False
SESSION_STATE_PATH.unlink(missing_ok=True)
print("Deleting sandbox...")
else:
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
_save_session_state(sandbox.state)
print("Saving memory and pausing sandbox (can take a couple of minutes)...")
finally:
if sandbox is not None:
if destroy:
# Skip memory flush — sandbox is being deleted.
await sandbox.stop()
await sandbox.shutdown()
else:
await sandbox.aclose()
await client.close()
if __name__ == "__main__":
asyncio.run(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
## Database: usaspending.db
NASA federal spending data from USAspending.gov. Each row is a single spending transaction (obligation or de-obligation) on a federal award.
### Table: spending
One row per transaction. Multiple transactions can share the same `award_id` (an award's initial obligation plus subsequent modifications, amendments, and de-obligations).
**Key columns:**
- `award_id` — unique award identifier (many transactions share one award_id)
- `award_piid_fain` — human-readable contract number (PIID) or assistance award number (FAIN)
- `parent_award_piid` — parent IDV contract number (links task orders to their contract vehicle; contracts only)
- `award_type` — 'contract', 'grant', 'idv', or 'other'
- `action_date` — date of this transaction (YYYY-MM-DD)
- `fiscal_year` — federal fiscal year (Oct-Sep; FY2024 = Oct 2023 - Sep 2024)
- `federal_action_obligation` — dollar amount of this transaction (can be negative for de-obligations)
- `total_obligation` — cumulative obligation for the entire award at time of this transaction
- `base_and_all_options_value` — total potential ceiling value including unexercised options (contracts only)
- `recipient_name` — who received the funds
- `recipient_parent_name` — parent company (e.g., subsidiaries roll up; contracts only)
- `recipient_state`, `recipient_city`, `recipient_country` — recipient location
- `awarding_office` — NASA center/office that made the award (e.g., 'GODDARD SPACE FLIGHT CENTER', 'JET PROPULSION LABORATORY')
- `funding_office` — NASA center/office providing funding (often same as awarding)
- `naics_code`, `naics_description` — industry classification (primarily for contracts)
- `psc_code`, `psc_description` — product/service classification
- `place_of_performance_state`, `place_of_performance_city` — where work is performed
- `period_of_perf_start`, `period_of_perf_end` — award period of performance dates (YYYY-MM-DD)
- `extent_competed` — competition level: 'Full and Open Competition', 'Not Competed', etc. (contracts only)
- `type_of_set_aside` — small business set-aside type: '8(a)', 'HUBZone', 'SDVOSB', etc. (contracts only)
- `number_of_offers` — number of offers received (contracts only)
- `contract_pricing_type` — pricing structure: 'Firm Fixed Price', 'Cost Plus', etc. (contracts only)
- `business_types` — recipient type for assistance: nonprofit, university, state govt, etc. (grants only)
- `description` — free-text description of the transaction
### Common query patterns
```sql
-- Total spending by fiscal year
SELECT fiscal_year, SUM(federal_action_obligation) AS total
FROM spending GROUP BY fiscal_year ORDER BY fiscal_year;
-- Top recipients (roll up by parent company)
SELECT COALESCE(NULLIF(recipient_parent_name, ''), recipient_name) AS entity,
SUM(federal_action_obligation) AS total
FROM spending GROUP BY entity ORDER BY total DESC LIMIT 10;
-- Spending by award type
SELECT award_type, COUNT(*), SUM(federal_action_obligation) AS total
FROM spending GROUP BY award_type;
-- Competitive vs sole-source contracts
SELECT extent_competed, COUNT(DISTINCT award_id) AS awards,
SUM(federal_action_obligation) AS total
FROM spending WHERE award_type = 'contract'
GROUP BY extent_competed ORDER BY total DESC;
-- Spending by NASA center
SELECT awarding_office, SUM(federal_action_obligation) AS total
FROM spending GROUP BY awarding_office ORDER BY total DESC;
```
@@ -0,0 +1,52 @@
# spending
One row per prime award transaction from NASA. Each row represents a financial action — an initial obligation, modification, amendment, or de-obligation on a federal award.
## Columns
| Column | Type | Description |
|--------|------|-------------|
| rowid | INTEGER PK | Auto-increment row identifier |
| award_id | TEXT | Unique award identifier. Multiple rows share the same award_id when an award has multiple transactions |
| award_piid_fain | TEXT | Human-readable award number: PIID for contracts (e.g., 'NNJ13ZBG001'), FAIN for assistance |
| parent_award_piid | TEXT | Parent IDV contract number. Links task/delivery orders to their parent contract vehicle (contracts only) |
| award_type | TEXT | Category: 'contract', 'grant', 'idv', or 'other' |
| description | TEXT | Free-text description of the transaction or award purpose |
| action_date | TEXT | Date of this transaction (ISO 8601: YYYY-MM-DD) |
| fiscal_year | INTEGER | Federal fiscal year (Oct-Sep; FY2024 = Oct 2023 - Sep 2024) |
| federal_action_obligation | REAL | Dollar amount of this specific transaction. Can be negative for de-obligations |
| total_obligation | REAL | Cumulative obligation for the entire award at the time of this transaction |
| base_and_all_options_value | REAL | Total potential ceiling value of the contract including all unexercised options. Contracts only; NULL for grants |
| recipient_name | TEXT | Legal name of the recipient organization |
| recipient_parent_name | TEXT | Parent company name (e.g., subsidiaries like 'Lockheed Martin Space' roll up to 'Lockheed Martin Corporation'). Contracts only; empty for grants |
| recipient_state | TEXT | Two-letter US state code of recipient's address. Empty for foreign recipients |
| recipient_city | TEXT | City of recipient's address |
| recipient_country | TEXT | Country name (e.g., 'UNITED STATES', 'UNITED KINGDOM') |
| awarding_office | TEXT | NASA center/office that made the award (e.g., 'GODDARD SPACE FLIGHT CENTER', 'JET PROPULSION LABORATORY'). Values are uppercase |
| funding_office | TEXT | NASA center/office providing funding (often same as awarding). Values are uppercase |
| naics_code | TEXT | North American Industry Classification System code. Primarily for contracts; may be empty for grants |
| naics_description | TEXT | Human-readable NAICS description |
| psc_code | TEXT | Product/Service Code for contracts, CFDA number for assistance. Different classification systems in the same column |
| psc_description | TEXT | Human-readable description of the PSC (contracts) or CFDA program (assistance) |
| place_of_performance_state | TEXT | State where work is performed. Two-letter codes for contracts, full names for assistance. May differ from recipient_state |
| place_of_performance_city | TEXT | City where work is performed |
| period_of_perf_start | TEXT | Award period of performance start date (YYYY-MM-DD) |
| period_of_perf_end | TEXT | Award period of performance end date (YYYY-MM-DD). This is the current end date and may reflect extensions |
| extent_competed | TEXT | Competition level. Values include 'Full and Open Competition', 'Not Available for Competition', 'Not Competed', etc. Contracts only; empty for grants |
| type_of_set_aside | TEXT | Small business set-aside type. Values include 'Small Business Set-Aside', '8(a) Set-Aside', 'HUBZone Set-Aside', 'Service-Disabled Veteran-Owned Small Business Set-Aside', 'Women-Owned Small Business', etc. Contracts only |
| number_of_offers | INTEGER | Number of offers/bids received. 1 = effectively sole-source even if technically competed. Contracts only; NULL for grants |
| contract_pricing_type | TEXT | Pricing structure: 'Firm Fixed Price', 'Cost Plus Fixed Fee', 'Cost No Fee', 'Time and Materials', etc. Contracts only |
| business_types | TEXT | Recipient organization type for assistance awards: nonprofit, university, state government, tribal, etc. Grants only; empty for contracts |
## Notes
- **Aggregating to award level**: use `GROUP BY award_id` with `SUM(federal_action_obligation)` to get total spending per award. The `total_obligation` column is a snapshot at each transaction and may not reflect the final total.
- **Contract ceiling vs obligation**: `base_and_all_options_value` is the potential maximum; `total_obligation` is what's actually committed. A contract may have $10M obligated against a $500M ceiling.
- **Parent company roll-up**: Use `COALESCE(NULLIF(recipient_parent_name, ''), recipient_name)` to group subsidiaries under their parent. Only populated for contracts.
- **recipient_name** may vary slightly for the same entity across rows (e.g., 'BOEING CO' vs 'THE BOEING COMPANY'). Use `LIKE` or `UPPER()` for fuzzy matching.
- **award_type** is derived from USAspending type codes: A/B/C/D -> 'contract', 02-05 -> 'grant', IDV_* -> 'idv'.
- **federal_action_obligation** can be negative (de-obligations, corrections). Sum them to get net spending.
- **naics_code** and **naics_description** are only populated for contracts; empty for grants/assistance.
- **psc_code** contains Product/Service Codes for contracts and CFDA numbers for assistance awards. **psc_description** contains the corresponding description. These are different classification systems stored in the same column.
- **Contracts-only columns**: `base_and_all_options_value`, `recipient_parent_name`, `parent_award_piid`, `extent_competed`, `type_of_set_aside`, `number_of_offers`, `contract_pricing_type` are only populated for contracts/IDVs.
- **Grants-only columns**: `business_types` is only populated for assistance awards.
@@ -0,0 +1,702 @@
#!/usr/bin/env python3
"""Download NASA spending data from USAspending.gov and build a SQLite database.
This script is designed to run inside a sandbox environment with only Python
stdlib available. It fetches data via the USAspending bulk download API,
parses the resulting CSVs, and creates a local SQLite database.
Usage:
python setup_db.py [--force] [--start-fy 2021] [--end-fy 2025]
The script is idempotent: it skips the download/build if the database already
exists unless --force is passed.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import csv
import json
import sqlite3
import sys
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
from typing import Any
DB_DIR = Path("data")
DB_PATH = DB_DIR / "usaspending.db"
GLOSSARY_PATH = Path("schema") / "glossary.md"
USASPENDING_API = "https://api.usaspending.gov"
BULK_DOWNLOAD_ENDPOINT = f"{USASPENDING_API}/api/v2/bulk_download/awards/"
DOWNLOAD_STATUS_ENDPOINT = f"{USASPENDING_API}/api/v2/download/status"
GLOSSARY_ENDPOINT = f"{USASPENDING_API}/api/v2/references/glossary/"
NASA_AGENCY = {
"type": "awarding",
"tier": "toptier",
"name": "National Aeronautics and Space Administration",
}
# Award type codes per the USAspending API contract.
CONTRACT_CODES = ["A", "B", "C", "D"]
GRANT_CODES = ["02", "03", "04", "05"]
IDV_CODES = ["IDV_A", "IDV_B", "IDV_B_A", "IDV_B_B", "IDV_B_C", "IDV_C", "IDV_D", "IDV_E"]
ALL_AWARD_CODES = CONTRACT_CODES + GRANT_CODES + IDV_CODES
AWARD_TYPE_MAP: dict[str, str] = {}
for _code in CONTRACT_CODES:
AWARD_TYPE_MAP[_code] = "contract"
for _code in GRANT_CODES:
AWARD_TYPE_MAP[_code] = "grant"
for _code in IDV_CODES:
AWARD_TYPE_MAP[_code] = "idv"
# Common headers — the USAspending WAF rejects requests without a User-Agent.
_HEADERS = {
"Content-Type": "application/json",
"User-Agent": "USAspending-setup/1.0 (universal_computer example)",
"Accept": "application/json",
}
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS spending (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
award_id TEXT,
award_piid_fain TEXT,
parent_award_piid TEXT,
award_type TEXT,
description TEXT,
action_date TEXT,
fiscal_year INTEGER,
federal_action_obligation REAL,
total_obligation REAL,
base_and_all_options_value REAL,
recipient_name TEXT,
recipient_parent_name TEXT,
recipient_state TEXT,
recipient_city TEXT,
recipient_country TEXT,
awarding_office TEXT,
funding_office TEXT,
naics_code TEXT,
naics_description TEXT,
psc_code TEXT,
psc_description TEXT,
place_of_performance_state TEXT,
place_of_performance_city TEXT,
period_of_perf_start TEXT,
period_of_perf_end TEXT,
extent_competed TEXT,
type_of_set_aside TEXT,
number_of_offers INTEGER,
contract_pricing_type TEXT,
business_types TEXT
);
CREATE INDEX IF NOT EXISTS idx_spending_award_id ON spending(award_id);
CREATE INDEX IF NOT EXISTS idx_spending_fiscal_year ON spending(fiscal_year);
CREATE INDEX IF NOT EXISTS idx_spending_award_type ON spending(award_type);
CREATE INDEX IF NOT EXISTS idx_spending_recipient ON spending(recipient_name);
CREATE INDEX IF NOT EXISTS idx_spending_recipient_parent ON spending(recipient_parent_name);
CREATE INDEX IF NOT EXISTS idx_spending_state ON spending(recipient_state);
CREATE INDEX IF NOT EXISTS idx_spending_action_date ON spending(action_date);
CREATE INDEX IF NOT EXISTS idx_spending_naics ON spending(naics_code);
CREATE INDEX IF NOT EXISTS idx_spending_obligation ON spending(federal_action_obligation);
CREATE INDEX IF NOT EXISTS idx_spending_extent_competed ON spending(extent_competed);
CREATE INDEX IF NOT EXISTS idx_spending_perf_start ON spending(period_of_perf_start);
CREATE INDEX IF NOT EXISTS idx_spending_awarding_office ON spending(awarding_office);
"""
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _urlopen_with_retry(
req: urllib.request.Request, *, timeout: int = 60, retries: int = 3
) -> bytes:
"""urlopen with retries for the flaky USAspending endpoints."""
last_exc: Exception | None = None
for attempt in range(1, retries + 1):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return bytes(resp.read())
except (urllib.error.URLError, ConnectionError, OSError) as e:
last_exc = e
if attempt < retries:
wait = 2**attempt
print(f" Retry {attempt}/{retries} after error: {e} (waiting {wait}s)")
time.sleep(wait)
raise RuntimeError(f"Request failed after {retries} attempts: {last_exc}") from last_exc
def api_post(url: str, payload: dict[str, Any]) -> dict[str, Any]:
"""POST JSON to a USAspending API endpoint and return the parsed response."""
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=_HEADERS, method="POST")
body = _urlopen_with_retry(req)
return json.loads(body.decode("utf-8")) # type: ignore[no-any-return]
def api_get(url: str) -> dict[str, Any]:
"""GET a USAspending API endpoint and return the parsed response."""
req = urllib.request.Request(url, headers=_HEADERS)
body = _urlopen_with_retry(req)
return json.loads(body.decode("utf-8")) # type: ignore[no-any-return]
# ---------------------------------------------------------------------------
# Bulk download
# ---------------------------------------------------------------------------
def submit_bulk_download(
award_types: list[str],
start_date: str,
end_date: str,
) -> tuple[str | None, str | None]:
"""Submit a bulk download request and return (status_url, file_url).
The USAspending bulk download API requires:
- filters.agencies: list of agency objects (name/tier/type)
- filters.prime_award_types: list of award type codes
- filters.date_type: "action_date" or "last_modified_date"
- filters.date_range: {start_date, end_date} (max 1 year span)
This only submits the request — call poll_download_status() to wait for completion.
"""
payload = {
"filters": {
"agencies": [NASA_AGENCY],
"prime_award_types": award_types,
"date_type": "action_date",
"date_range": {
"start_date": start_date,
"end_date": end_date,
},
},
"file_format": "csv",
}
resp = api_post(BULK_DOWNLOAD_ENDPOINT, payload)
file_url = resp.get("file_url")
status_url = resp.get("status_url")
if not status_url and not file_url:
raise RuntimeError(f"Unexpected API response: {resp}")
return status_url, file_url
def poll_download_status(status_url: str | None, file_url: str | None) -> str:
"""Poll the download status endpoint until the file is ready."""
if not status_url:
if file_url:
return file_url
raise RuntimeError("No status_url or file_url to poll")
for attempt in range(120):
try:
status = api_get(status_url)
except Exception:
time.sleep(5)
continue
state = status.get("status", "unknown")
if state == "finished":
return status.get("file_url") or file_url or ""
elif state == "failed":
raise RuntimeError(f"Download generation failed: {status.get('message', 'unknown')}")
if attempt % 6 == 0:
print(f" Generating... (status: {state})")
time.sleep(5)
raise RuntimeError("Timed out waiting for download (10 minutes)")
def download_and_extract(file_url: str, extract_dir: Path) -> list[Path]:
"""Download a zip file and extract CSVs to extract_dir."""
extract_dir.mkdir(parents=True, exist_ok=True)
zip_path = extract_dir / "download.zip"
print(" Downloading...")
req = urllib.request.Request(file_url, headers={"User-Agent": _HEADERS["User-Agent"]})
data = _urlopen_with_retry(req, timeout=300, retries=3)
zip_path.write_bytes(data)
file_size_mb = len(data) / (1024 * 1024)
print(f" Downloaded {file_size_mb:.1f} MB")
print(" Extracting CSV files...")
csv_files = []
with zipfile.ZipFile(zip_path, "r") as zf:
for name in zf.namelist():
if name.endswith(".csv"):
zf.extract(name, extract_dir)
csv_files.append(extract_dir / name)
print(f" {name}")
zip_path.unlink()
return csv_files
# ---------------------------------------------------------------------------
# CSV ingestion
# ---------------------------------------------------------------------------
def safe_float(val: str) -> float | None:
if not val or val.strip() == "":
return None
try:
return float(val.replace(",", ""))
except ValueError:
return None
def safe_int(val: str) -> int | None:
if not val or val.strip() == "":
return None
try:
return int(val.strip())
except ValueError:
return None
def classify_award_type(type_code: str, award_id: str) -> str:
mapped = AWARD_TYPE_MAP.get(type_code)
if mapped:
return mapped
# Fallback: detect IDVs from the award_id prefix when the type code
# doesn't match our expected IDV codes.
if award_id.startswith("CONT_IDV_"):
return "idv"
return "other"
def _detect_csv_type(headers: set[str]) -> str:
"""Detect whether a CSV is contracts or assistance based on its headers.
Per the USAspending data dictionary, PrimeAwardUniqueKey is stored as
'contract_award_unique_key' in contracts and 'assistance_award_unique_key'
in assistance.
"""
if "contract_award_unique_key" in headers:
return "contracts"
if "assistance_award_unique_key" in headers:
return "assistance"
raise ValueError(
"Cannot detect CSV type: neither 'contract_award_unique_key' nor "
"'assistance_award_unique_key' found in headers"
)
# Column mappings per CSV type, derived from the USAspending data dictionary
# (https://api.usaspending.gov/api/v2/references/data_dictionary/).
#
# "shared" columns have the same name in both contracts and assistance CSVs.
# Type-specific columns are listed under "contracts" and "assistance".
# Column mappings verified against actual CSV headers downloaded from USAspending
# on 2026-03-26, and cross-referenced with the data dictionary API at
# https://api.usaspending.gov/api/v2/references/data_dictionary/.
#
# "shared" columns have the same name in both contracts and assistance CSVs.
# Type-specific columns differ between the two and are listed separately.
_SHARED_COLUMNS = {
# db_column -> csv_column
"action_date": "action_date",
"fiscal_year": "action_date_fiscal_year",
"federal_action_obligation": "federal_action_obligation",
"recipient_name": "recipient_name",
"recipient_state": "recipient_state_code",
"recipient_city": "recipient_city_name",
"recipient_country": "recipient_country_name",
"awarding_office": "awarding_office_name",
"funding_office": "funding_office_name",
"description": "transaction_description",
"place_of_performance_city": "primary_place_of_performance_city_name",
"period_of_perf_start": "period_of_performance_start_date",
"period_of_perf_end": "period_of_performance_current_end_date",
}
_TYPE_COLUMNS: dict[str, dict[str, str]] = {
"contracts": {
"award_id": "contract_award_unique_key",
"award_piid_fain": "award_id_piid",
"parent_award_piid": "parent_award_id_piid",
"award_type_code": "award_type_code",
"total_obligation": "total_dollars_obligated",
"base_and_all_options_value": "base_and_all_options_value",
"recipient_parent_name": "recipient_parent_name",
"place_of_performance_state": "primary_place_of_performance_state_code",
"naics_code": "naics_code",
"naics_description": "naics_description",
"psc_code": "product_or_service_code",
"psc_description": "product_or_service_code_description",
"extent_competed": "extent_competed",
"type_of_set_aside": "type_of_set_aside",
"number_of_offers": "number_of_offers_received",
"contract_pricing_type": "type_of_contract_pricing",
"business_types": "", # not present in contracts CSVs
},
"assistance": {
"award_id": "assistance_award_unique_key",
"award_piid_fain": "award_id_fain",
"parent_award_piid": "", # not applicable to assistance
"award_type_code": "assistance_type_code",
"total_obligation": "total_obligated_amount",
"base_and_all_options_value": "", # contracts only
"recipient_parent_name": "", # contracts only
"place_of_performance_state": "primary_place_of_performance_state_name",
"naics_code": "", # not present in assistance CSVs
"naics_description": "",
"psc_code": "cfda_number",
"psc_description": "cfda_title",
"extent_competed": "", # contracts only
"type_of_set_aside": "", # contracts only
"number_of_offers": "", # contracts only
"contract_pricing_type": "", # contracts only
"business_types": "business_types_description",
},
}
def ingest_csv(db: sqlite3.Connection, csv_path: Path) -> int:
"""Ingest a USAspending prime transactions CSV into the spending table."""
count = 0
with open(csv_path, encoding="utf-8", errors="replace") as f:
reader = csv.DictReader(f)
if reader.fieldnames is None:
return 0
headers = set(reader.fieldnames)
csv_type = _detect_csv_type(headers)
type_cols = _TYPE_COLUMNS[csv_type]
# Verify expected columns exist
all_expected = dict(_SHARED_COLUMNS)
all_expected.update(type_cols)
missing = [
db_col for db_col, csv_col in all_expected.items() if csv_col and csv_col not in headers
]
if missing:
print(f" Warning: missing expected columns: {missing}")
award_id_col = type_cols["award_id"]
award_type_col = type_cols["award_type_code"]
for row in reader:
award_id = row.get(award_id_col, "")
if not award_id:
continue
type_code = row.get(award_type_col, "")
award_type = classify_award_type(type_code, award_id)
def col(db_name: str, _row: dict[str, str] = row) -> str:
"""Look up a value: type-specific columns first, then shared."""
csv_col = type_cols.get(db_name) or _SHARED_COLUMNS.get(db_name, "")
return _row.get(csv_col, "") if csv_col else ""
db.execute(
"""INSERT INTO spending
(award_id, award_piid_fain, parent_award_piid,
award_type, description, action_date, fiscal_year,
federal_action_obligation, total_obligation, base_and_all_options_value,
recipient_name, recipient_parent_name,
recipient_state, recipient_city, recipient_country,
awarding_office, funding_office,
naics_code, naics_description, psc_code, psc_description,
place_of_performance_state, place_of_performance_city,
period_of_perf_start, period_of_perf_end,
extent_competed, type_of_set_aside, number_of_offers,
contract_pricing_type, business_types)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
award_id,
col("award_piid_fain"),
col("parent_award_piid"),
award_type,
col("description"),
col("action_date"),
safe_int(col("fiscal_year")),
safe_float(col("federal_action_obligation")),
safe_float(col("total_obligation")),
safe_float(col("base_and_all_options_value")),
col("recipient_name"),
col("recipient_parent_name"),
col("recipient_state"),
col("recipient_city"),
col("recipient_country"),
col("awarding_office"),
col("funding_office"),
col("naics_code"),
col("naics_description"),
col("psc_code"),
col("psc_description"),
col("place_of_performance_state"),
col("place_of_performance_city"),
col("period_of_perf_start"),
col("period_of_perf_end"),
col("extent_competed"),
col("type_of_set_aside"),
safe_int(col("number_of_offers")),
col("contract_pricing_type"),
col("business_types"),
),
)
count += 1
return count
def build_database(csv_files: list[Path]) -> None:
"""Build the SQLite database from extracted CSV files."""
DB_DIR.mkdir(parents=True, exist_ok=True)
print(f"Creating database at {DB_PATH}...")
db = sqlite3.connect(str(DB_PATH))
db.executescript(SCHEMA_SQL)
total = 0
for csv_path in csv_files:
print(f" Ingesting {csv_path.name}...")
count = ingest_csv(db, csv_path)
total += count
print(f" {count:,} rows")
db.commit()
cursor = db.execute("SELECT COUNT(*) FROM spending")
rows_stored = cursor.fetchone()[0]
cursor = db.execute("SELECT COUNT(DISTINCT award_id) FROM spending")
unique_awards = cursor.fetchone()[0]
db.close()
db_size_mb = DB_PATH.stat().st_size / (1024 * 1024)
print(f"\nDatabase built: {DB_PATH}")
print(f" Rows: {rows_stored:,}")
print(f" Unique awards: {unique_awards:,}")
print(f" Size: {db_size_mb:.1f} MB")
# ---------------------------------------------------------------------------
# Glossary
# ---------------------------------------------------------------------------
def fetch_glossary() -> None:
"""Fetch the official USAspending glossary and write it to schema/glossary.md."""
if GLOSSARY_PATH.exists():
print(f"Glossary already exists at {GLOSSARY_PATH}, skipping.")
return
GLOSSARY_PATH.parent.mkdir(parents=True, exist_ok=True)
print("Fetching USAspending glossary...")
try:
resp = api_get(f"{GLOSSARY_ENDPOINT}?limit=500")
except Exception as e:
print(f" Warning: failed to fetch glossary: {e}")
return
results = resp.get("results", [])
if not results:
print(" Warning: glossary API returned no results.")
return
results.sort(key=lambda t: t.get("term", "").lower())
lines = [
"# USAspending Glossary",
"",
"Official definitions from [USAspending.gov](https://www.usaspending.gov).",
f"Retrieved automatically by setup_db.py ({len(results)} terms).",
"",
]
for entry in results:
term = entry.get("term", "").strip()
plain = (entry.get("plain") or "").strip()
official = (entry.get("official") or "").strip()
if not term:
continue
lines.append(f"## {term}")
lines.append("")
if plain:
lines.append(plain)
lines.append("")
if official and official != plain:
lines.append(f"**Official definition:** {official}")
lines.append("")
GLOSSARY_PATH.write_text("\n".join(lines), encoding="utf-8")
print(f" Wrote {len(results)} glossary terms to {GLOSSARY_PATH}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def fiscal_year_dates(fy: int) -> tuple[str, str]:
"""Return (start_date, end_date) for a federal fiscal year.
Federal FY runs Oct 1 of the prior calendar year through Sep 30.
Example: FY2024 = 2023-10-01 to 2024-09-30.
"""
return f"{fy - 1}-10-01", f"{fy}-09-30"
def main() -> None:
parser = argparse.ArgumentParser(description="Build NASA USAspending SQLite database")
parser.add_argument("--force", action="store_true", help="Rebuild even if database exists")
parser.add_argument(
"--start-fy", type=int, default=2021, help="First fiscal year to download (default: 2021)"
)
parser.add_argument(
"--end-fy", type=int, default=2025, help="Last fiscal year to download (default: 2025)"
)
args = parser.parse_args()
if args.start_fy > args.end_fy:
parser.error(f"--start-fy ({args.start_fy}) must be <= --end-fy ({args.end_fy})")
requested_fys = set(range(args.start_fy, args.end_fy + 1))
if DB_PATH.exists() and not args.force:
# Verify the existing DB covers all requested fiscal years.
try:
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
rows = conn.execute("SELECT DISTINCT fiscal_year FROM spending").fetchall()
conn.close()
present_fys = {int(r[0]) for r in rows if r[0] is not None}
missing_fys = requested_fys - present_fys
if not missing_fys:
db_size_mb = DB_PATH.stat().st_size / (1024 * 1024)
print(
f"Database already exists at {DB_PATH} ({db_size_mb:.1f} MB) "
f"with all requested FYs. Use --force to rebuild."
)
return
print(
f"Database exists but is missing FY data for: "
f"{', '.join(str(fy) for fy in sorted(missing_fys))}. Rebuilding..."
)
except Exception:
print("Database exists but could not be verified. Rebuilding...")
DB_PATH.unlink()
elif DB_PATH.exists():
DB_PATH.unlink()
tmp_dir = Path("data/tmp_download")
print("=== NASA USAspending Database Builder ===")
print(f"Fiscal years: {args.start_fy} - {args.end_fy}\n")
# The bulk download API limits date_range to 1 year, so we request
# one fiscal year at a time. We submit all requests upfront so the
# server-side assembly (the slow part) runs concurrently, then poll
# and download the results.
all_csv_files: list[Path] = []
failed_fys: list[int] = []
fiscal_years = list(range(args.start_fy, args.end_fy + 1))
# Phase 1: Submit all bulk download requests concurrently.
print("Submitting download requests...")
pending: dict[int, tuple[str | None, str | None]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fiscal_years)) as pool:
def _submit(fy: int) -> tuple[int, str | None, str | None]:
start_date, end_date = fiscal_year_dates(fy)
status_url, file_url = submit_bulk_download(
ALL_AWARD_CODES,
start_date,
end_date,
)
return fy, status_url, file_url
futures = {pool.submit(_submit, fy): fy for fy in fiscal_years}
for future in concurrent.futures.as_completed(futures):
fy = futures[future]
try:
_, status_url, file_url = future.result()
pending[fy] = (status_url, file_url)
print(f" FY{fy}: submitted")
except Exception as e:
print(f" FY{fy}: submit failed: {e}")
failed_fys.append(fy)
# Phase 2: Poll all pending requests until ready, then download.
for fy in sorted(pending):
print(f"\n--- FY{fy} ---")
status_url, file_url = pending[fy]
try:
file_url = poll_download_status(status_url, file_url)
print(f" Ready: {file_url}")
fy_dir = tmp_dir / f"fy{fy}"
csv_files = download_and_extract(file_url, fy_dir)
all_csv_files.extend(csv_files)
except Exception as e:
print(f" Error: failed FY{fy}: {e}")
failed_fys.append(fy)
if not all_csv_files:
print("\nError: no data downloaded. Check internet connectivity.")
sys.exit(1)
if failed_fys:
print(
f"\nError: failed to download data for: "
f"{', '.join(f'FY{fy}' for fy in failed_fys)}. "
f"Cannot build a complete database."
)
sys.exit(1)
print("\n--- Fetching glossary ---")
fetch_glossary()
print("\n--- Building database ---")
build_database(all_csv_files)
# Verify the built DB covers all requested fiscal years.
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
rows = conn.execute("SELECT DISTINCT fiscal_year FROM spending").fetchall()
conn.close()
present_fys = {int(r[0]) for r in rows if r[0] is not None}
missing_fys = requested_fys - present_fys
if missing_fys:
print(
f"\nError: database built but missing data for: "
f"{', '.join(f'FY{fy}' for fy in sorted(missing_fys))}. "
f"Downloaded files may have been empty."
)
DB_PATH.unlink()
sys.exit(1)
# Clean up temp files
for f in tmp_dir.rglob("*"):
if f.is_file():
f.unlink()
for d in sorted(tmp_dir.rglob("*"), reverse=True):
if d.is_dir():
d.rmdir()
if tmp_dir.exists():
tmp_dir.rmdir()
print("\nDone!")
if __name__ == "__main__":
main()
@@ -0,0 +1,175 @@
from __future__ import annotations
import textwrap
from typing import Any, Literal
from agents.sandbox import Capability, ExecTimeoutError, Manifest
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.tool import FunctionTool
# Python script executed inside the sandbox to run SQL queries safely.
# Receives the query on stdin, enforces read-only mode and row limits.
_QUERY_RUNNER_SCRIPT = r"""
import csv, json, os, sqlite3, sys, time
db_path = sys.argv[1]
display_limit = int(sys.argv[2])
csv_limit = int(sys.argv[3])
results_dir = sys.argv[4] if len(sys.argv) > 4 else ""
query = sys.stdin.read().strip()
if not query:
print("Error: empty query")
sys.exit(0)
# Statement-level validation: only allow read-only operations
first_token = query.lstrip().split()[0].upper() if query.strip() else ""
if first_token not in ("SELECT", "WITH", "EXPLAIN", "PRAGMA"):
print(f"Error: only SELECT, WITH, EXPLAIN, and PRAGMA statements are allowed (got {first_token})")
sys.exit(0)
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn.execute("PRAGMA query_only = ON")
cursor = conn.execute(query)
columns = [desc[0] for desc in cursor.description] if cursor.description else []
rows = cursor.fetchmany(csv_limit + 1)
conn.close()
except sqlite3.Error as e:
print(f"SQL error: {e}")
sys.exit(0)
if not columns:
print(json.dumps({"columns": [], "rows": [], "row_count": 0, "truncated": False}))
sys.exit(0)
csv_truncated = len(rows) > csv_limit
if csv_truncated:
rows = rows[:csv_limit]
# Save full result as CSV for download
csv_file = ""
if results_dir:
os.makedirs(results_dir, exist_ok=True)
csv_file = f"query_{int(time.time())}_{os.getpid()}.csv"
with open(os.path.join(results_dir, csv_file), "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(columns)
writer.writerows(rows)
# Return only display_limit rows to the model, but report total counts
total_rows = len(rows)
display_rows = rows[:display_limit]
result = {
"columns": columns,
"rows": display_rows,
"row_count": total_rows,
"display_count": len(display_rows),
"truncated": csv_truncated,
}
if csv_file:
result["csv_file"] = csv_file
if total_rows > len(display_rows):
result["note"] = f"Showing {len(display_rows)} of {total_rows} rows. Full result saved to CSV."
print(json.dumps(result))
"""
def _shell_quote(s: str) -> str:
"""Single-quote a string for safe shell interpolation."""
return "'" + s.replace("'", "'\\''") + "'"
_SQL_CAPABILITY_INSTRUCTIONS = textwrap.dedent(
"""\
When querying the database:
- Always use `run_sql` to execute SQL. Never run sqlite3 directly via a shell.
- Write standard SQLite-compatible SQL.
- Prefer aggregations (GROUP BY, SUM, COUNT, AVG) over returning many raw rows.
- The display shows up to 100 rows, but up to 10,000 rows are saved to a downloadable CSV.
If the user needs a large export, let them know the full result is available via the download link.
- Use the schema documentation files in schema/tables/ if you need column details.
- Read schema/glossary.md for official definitions of USAspending terms.
- For monetary values, the database stores amounts in dollars as REAL values.
"""
).strip()
def _make_run_sql_tool(
session: BaseSandboxSession,
db_path: str,
max_display_rows: int,
max_csv_rows: int,
timeout_seconds: float,
results_dir: str,
) -> FunctionTool:
"""Build a FunctionTool that executes read-only SQL inside the sandbox."""
async def run_sql(query: str, limit: int | None = None) -> str:
"""Execute a read-only SQL query against the NASA USAspending SQLite database.
Returns results as JSON with columns, rows, row_count, and truncated fields.
Results are also saved as a downloadable CSV. The display is limited to a
small number of rows, but the CSV may contain many more.
Args:
query: SQL SELECT query to execute against the USAspending database.
Only read-only queries are allowed.
limit: Optional display row limit override.
"""
display_limit = max(1, min(limit or max_display_rows, max_display_rows))
command = (
f"printf '%s' {_shell_quote(query)} "
f"| python3 -c {_shell_quote(_QUERY_RUNNER_SCRIPT)} "
f"{_shell_quote(db_path)} {display_limit} {max_csv_rows}"
f" {_shell_quote(results_dir)}"
)
try:
result = await session.exec(command, timeout=timeout_seconds)
except (ExecTimeoutError, TimeoutError):
return f"Query timed out after {timeout_seconds}s. Try a simpler query or add a LIMIT."
output = result.stdout.decode("utf-8", errors="replace")
stderr = result.stderr.decode("utf-8", errors="replace")
if not result.ok():
return f"Execution error (exit {result.exit_code}):\n{stderr or output}"
return output.strip() if output.strip() else "Query returned no results."
from agents.tool import function_tool as _function_tool
return _function_tool(run_sql, name_override="run_sql")
class SqlCapability(Capability):
type: Literal["sql"] = "sql"
db_path: str = "data/usaspending.db"
max_display_rows: int = 100
max_csv_rows: int = 10_000
timeout_seconds: float = 30.0
results_dir: str = "results"
def bind(self, session: BaseSandboxSession) -> None:
self.session = session
def tools(self) -> list[Any]:
if self.session is None:
raise ValueError("SqlCapability is not bound to a SandboxSession")
return [
_make_run_sql_tool(
session=self.session,
db_path=self.db_path,
max_display_rows=self.max_display_rows,
max_csv_rows=self.max_csv_rows,
timeout_seconds=self.timeout_seconds,
results_dir=self.results_dir,
)
]
async def instructions(self, manifest: Manifest) -> str | None:
return _SQL_CAPABILITY_INSTRUCTIONS
+273
View File
@@ -0,0 +1,273 @@
"""
Minimal E2B-backed sandbox example for manual validation.
This example is intentionally small: it creates a tiny workspace, lets the
agent inspect it through one shell tool, and prints a short answer.
"""
import argparse
import asyncio
import io
import os
import sys
import tempfile
from pathlib import Path
from typing import Literal
from openai.types.responses import ResponseTextDeltaEvent
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
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 (
E2BSandboxClient,
E2BSandboxClientOptions,
E2BSandboxType,
)
except Exception as exc: # pragma: no cover - import path depends on optional extras
raise SystemExit(
"E2B sandbox examples require the optional repo extra.\n"
"Install it with: uv sync --extra e2b"
) from exc
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
DEFAULT_SANDBOX_TYPE = E2BSandboxType.E2B.value
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
SNAPSHOT_CHECK_CONTENT = "e2b snapshot round-trip ok\n"
def _build_manifest() -> Manifest:
return text_manifest(
{
"README.md": (
"# Renewal Notes\n\n"
"This workspace contains a tiny account review packet for manual sandbox testing.\n"
),
"customer.md": (
"# Customer\n\n"
"- Name: Northwind Health.\n"
"- Renewal date: 2026-04-15.\n"
"- Risk: unresolved SSO setup.\n"
),
"next_steps.md": (
"# Next steps\n\n"
"1. Finish the SSO fix.\n"
"2. Confirm legal language before procurement review.\n"
),
}
)
def _require_env(name: str) -> None:
if os.environ.get(name):
return
raise SystemExit(f"{name} must be set before running this example.")
def _rewrite_template_resolution_error(exc: Exception) -> None:
message = str(exc)
marker = "error resolving template '"
if marker not in message:
return
template = message.split(marker, 1)[1].split("'", 1)[0]
raise SystemExit(
f"E2B could not resolve template `{template}`.\n"
"Pass `--template <your-template>` with a template that exists for this E2B account/team. "
"If you were relying on the example default, the SDK default template for this backend is "
"not available in your current E2B environment."
) from exc
async def _verify_stop_resume(
*,
sandbox_type: Literal["e2b_code_interpreter", "e2b"],
template: str | None,
timeout: int | None,
pause_on_exit: bool,
workspace_persistence: Literal["tar", "snapshot"],
) -> None:
client = E2BSandboxClient()
with tempfile.TemporaryDirectory(prefix="e2b-snapshot-example-") as snapshot_dir:
sandbox = await client.create(
manifest=_build_manifest(),
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
options=E2BSandboxClientOptions(
sandbox_type=E2BSandboxType(sandbox_type),
template=template,
timeout=timeout,
pause_on_exit=pause_on_exit,
workspace_persistence=workspace_persistence,
),
)
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 = await resumed_sandbox.read(SNAPSHOT_CHECK_PATH)
restored_text = restored.read()
if isinstance(restored_text, bytes):
restored_text = restored_text.decode("utf-8")
if restored_text != SNAPSHOT_CHECK_CONTENT:
raise RuntimeError(
"Snapshot resume verification failed for "
f"{sandbox_type!r}: expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}"
)
finally:
await resumed_sandbox.shutdown()
print(f"snapshot round-trip ok ({sandbox_type}, {workspace_persistence})")
async def main(
*,
model: str,
question: str,
sandbox_type: Literal["e2b_code_interpreter", "e2b"],
template: str | None,
timeout: int | None,
pause_on_exit: bool,
workspace_persistence: Literal["tar", "snapshot"],
stream: bool,
) -> None:
_require_env("OPENAI_API_KEY")
_require_env("E2B_API_KEY")
try:
await _verify_stop_resume(
sandbox_type=sandbox_type,
template=template,
timeout=timeout,
pause_on_exit=pause_on_exit,
workspace_persistence=workspace_persistence,
)
except Exception as exc:
_rewrite_template_resolution_error(exc)
raise
manifest = _build_manifest()
agent = SandboxAgent(
name="E2B 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=E2BSandboxClient(),
options=E2BSandboxClientOptions(
sandbox_type=E2BSandboxType(sandbox_type),
template=template,
timeout=timeout,
pause_on_exit=pause_on_exit,
workspace_persistence=workspace_persistence,
),
),
workflow_name="E2B sandbox example",
)
if not stream:
try:
result = await Runner.run(agent, question, run_config=run_config)
except Exception as exc:
_rewrite_template_resolution_error(exc)
raise
print(result.final_output)
return
try:
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
except Exception as exc:
_rewrite_template_resolution_error(exc)
raise
saw_text_delta = False
try:
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)
except Exception as exc:
_rewrite_template_resolution_error(exc)
raise
if saw_text_delta:
print()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
parser.add_argument(
"--sandbox-type",
default=DEFAULT_SANDBOX_TYPE,
choices=[member.value for member in E2BSandboxType],
help=(
"E2B sandbox interface to create. `e2b` provides a bash-style interface; "
"`e2b_code_interpreter` provides a Jupyter-style interface."
),
)
parser.add_argument("--template", default=None, help="Optional E2B template name.")
parser.add_argument(
"--timeout",
type=int,
default=300,
help="Optional E2B sandbox timeout in seconds.",
)
parser.add_argument(
"--pause-on-exit",
action="store_true",
default=False,
help="Pause the sandbox on shutdown instead of killing it.",
)
parser.add_argument(
"--workspace-persistence",
default="tar",
choices=["tar", "snapshot"],
help="Workspace persistence mode for the E2B sandbox.",
)
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,
sandbox_type=args.sandbox_type,
template=args.template,
timeout=args.timeout,
pause_on_exit=args.pause_on_exit,
workspace_persistence=args.workspace_persistence,
stream=args.stream,
)
)
+366
View File
@@ -0,0 +1,366 @@
"""
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.4", 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,
)
)
@@ -0,0 +1,995 @@
from __future__ import annotations
import argparse
import asyncio
import io
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import urljoin
from openai.types.responses import ResponseTextDeltaEvent
from pydantic import BaseModel
from agents import Agent, ModelSettings, Runner, function_tool
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
from examples.sandbox.misc.example_support import text_manifest, tool_call_name
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
try:
from agents.extensions.sandbox import (
DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT,
DEFAULT_RUNLOOP_WORKSPACE_ROOT,
RunloopAfterIdle,
RunloopGatewaySpec,
RunloopLaunchParameters,
RunloopMcpSpec,
RunloopSandboxClient,
RunloopSandboxClientOptions,
RunloopSandboxSessionState,
RunloopTunnelConfig,
RunloopUserParameters,
)
except Exception as exc: # pragma: no cover - import path depends on optional extras
raise SystemExit(
"Runloop sandbox examples require the optional repo extra.\n"
"Install it with: uv sync --extra runloop"
) from exc
DEFAULT_MODEL = "gpt-5.4"
DEFAULT_HTTP_PORT = 8123
DEFAULT_AGENT_PROMPT = (
"Inspect this Runloop sandbox workspace, verify the configuration using the shell tool, "
"and summarize which Runloop-specific capabilities were exercised."
)
EXAMPLE_RESOURCE_SLUG = "runloop-capabilities-example"
PERSISTENT_SECRET_NAME = "RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN"
PERSISTENT_SECRET_VALUE = "runloop-capabilities-example-token"
PERSISTENT_NETWORK_POLICY_NAME = "runloop-capabilities-example-policy"
HTTP_LOG_PATH = Path(".runloop-http.log")
RUNTIME_CONTEXT_PATH = Path("runtime_context.json")
AGENT_PROOF_PATH = Path("verification/agent-proof.txt")
class RunloopResourceQueryResult(BaseModel):
resource_type: Literal["secret", "network_policy"]
name: str
found: bool
id: str | None = None
description: str | None = None
class RunloopResourceBootstrapResult(BaseModel):
resource_type: Literal["secret", "network_policy"]
name: str
action: Literal["created", "reused", "override"]
id: str | None = None
found_before_bootstrap: bool
def _phase(title: str) -> None:
print(f"\n=== {title} ===", flush=True)
def _require_env(name: str) -> None:
if os.environ.get(name):
return
raise SystemExit(f"{name} must be set before running this example.")
def _run_id() -> str:
return uuid.uuid4().hex[:8]
def _summarize_resource(item: object, fields: tuple[str, ...]) -> dict[str, object]:
summary: dict[str, object] = {}
for field in fields:
value = getattr(item, field, None)
if value is not None:
summary[field] = value
return summary
async def _collect_async_items(items: Any, *, limit: int) -> list[Any]:
collected: list[Any] = []
async for item in items:
collected.append(item)
if len(collected) >= limit:
break
return collected
def _status_code(exc: BaseException) -> int | None:
status_code = getattr(exc, "status_code", None)
if isinstance(status_code, int):
return status_code
response = getattr(exc, "response", None)
response_status = getattr(response, "status_code", None)
return response_status if isinstance(response_status, int) else None
def _is_not_found(exc: BaseException) -> bool:
return _status_code(exc) == 404
def _error_message(exc: BaseException) -> str | None:
message = getattr(exc, "message", None)
if isinstance(message, str):
return message
body = getattr(exc, "body", None)
if isinstance(body, dict):
body_message = body.get("message")
if isinstance(body_message, str):
return body_message
return None
def _is_conflict(exc: BaseException) -> bool:
status_code = _status_code(exc)
if status_code == 409:
return True
if status_code == 400:
message = _error_message(exc)
return isinstance(message, str) and "already exists" in message.lower()
return False
async def _collect_maybe_async_items(items: Any, *, limit: int) -> list[Any]:
if hasattr(items, "__aiter__"):
return await _collect_async_items(items, limit=limit)
return list(items)[:limit]
async def _read_text(session: Any, path: Path) -> str:
data = await session.read(path)
try:
payload = data.read()
finally:
data.close()
if isinstance(payload, bytes):
return payload.decode("utf-8")
return str(payload)
async def _write_json(session: Any, path: Path, payload: dict[str, object]) -> None:
await session.write(
path, io.BytesIO(json.dumps(payload, indent=2, sort_keys=True).encode("utf-8"))
)
def _build_manifest(*, workspace_root: str, context: dict[str, object]) -> Manifest:
manifest = text_manifest(
{
"README.md": (
"# Runloop Capabilities Example\n\n"
"This workspace is used to validate the Runloop-specific sandbox integration end "
"to end.\n"
),
"checklist.md": (
"# Checklist\n\n"
"1. Inspect the workspace.\n"
"2. Verify the resource discovery results in the context files.\n"
"3. Confirm the managed secret is available without printing its full value.\n"
"4. Confirm the HTTP preview server and verification file.\n"
"5. Summarize what Runloop-native features were exercised and whether persistent "
"resources were reused or created.\n"
),
"platform_context.json": json.dumps(context, indent=2, sort_keys=True) + "\n",
}
)
return Manifest(root=workspace_root, entries=manifest.entries)
def _build_sandbox_agent(
*, model: str, manifest: Manifest, managed_secret_name: str
) -> SandboxAgent:
return SandboxAgent(
name="Runloop Capabilities Guide",
model=model,
instructions=(
"Inspect the Runloop sandbox workspace carefully before answering. Use the shell tool "
"to verify what happened in the environment and keep the final response concise. "
"Follow this sequence:\n"
"1. Run `pwd` and `find . -maxdepth 3 -type f | sort`.\n"
"2. Read `README.md`, `checklist.md`, `platform_context.json`, and `runtime_context.json`.\n"
"3. Report whether the managed secret and network policy existed before bootstrap by "
"reading the query/bootstrap summaries from the context files.\n"
f"4. Confirm whether `${managed_secret_name}` is set, but never print the full value. "
"Only report whether it exists and its character length.\n"
f"5. Read `{HTTP_LOG_PATH.as_posix()}` and confirm the HTTP server started.\n"
f"6. Create `{AGENT_PROOF_PATH.as_posix()}` with these exact lines:\n"
" runloop_capabilities_verified=true\n"
" managed_secret_checked=true\n"
" tunnel_verified=true\n"
"7. Print that verification file from the shell.\n"
"8. Final answer: 2 short sentences naming the specific Runloop features exercised, "
"including whether the persistent secret and policy were reused or created.\n"
"Only mention facts you verified from files, environment inspection, or shell output."
),
default_manifest=manifest,
capabilities=[WorkspaceShellCapability()],
model_settings=ModelSettings(tool_choice="required"),
)
def _build_query_agent(
*,
model: str,
query_secret_tool: Any,
query_policy_tool: Any,
managed_secret_name: str,
network_policy_name: str,
) -> Agent:
return Agent(
name="Runloop Resource Discovery Guide",
model=model,
instructions=(
"Use the provided Runloop query tools to check whether the persistent example "
"resources already exist before any create step. Keep the final answer concise."
),
tools=[query_secret_tool, query_policy_tool],
model_settings=ModelSettings(tool_choice="required"),
).clone(
instructions=(
"Use the provided Runloop query tools to check whether the persistent example "
"resources already exist before any create step. Keep the final answer concise."
),
handoff_description=None,
output_type=None,
)
def _stream_event_banner(event_name: str) -> str | None:
if event_name == "tool_called":
return "[tool call]"
if event_name == "tool_output":
return "[tool output]"
return None
def _runloop_state(session: Any) -> RunloopSandboxSessionState:
return cast(RunloopSandboxSessionState, session.state)
async def _run_plain_agent(
*,
agent: Agent,
prompt: str,
workflow_name: str,
stream: bool,
) -> str:
if not stream:
result = await Runner.run(agent, prompt, run_config=RunConfig(workflow_name=workflow_name))
print(result.final_output)
return str(result.final_output)
stream_result = Runner.run_streamed(
agent,
prompt,
run_config=RunConfig(workflow_name=workflow_name),
)
saw_text_delta = False
saw_any_text = 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)
saw_any_text = True
continue
if event.type != "run_item_stream_event":
continue
banner = _stream_event_banner(event.name)
if banner is None:
continue
if saw_text_delta:
print()
saw_text_delta = False
print(f"{banner}: {tool_call_name(event.item.raw_item) or 'tool'}", flush=True)
if saw_text_delta:
print()
if not saw_any_text:
print(stream_result.final_output)
return str(stream_result.final_output)
async def _run_sandbox_agent(
*,
agent: SandboxAgent,
prompt: str,
session: Any,
workflow_name: str,
stream: bool,
) -> str:
if not stream:
result = await Runner.run(
agent,
prompt,
run_config=RunConfig(
sandbox=SandboxRunConfig(session=session),
workflow_name=workflow_name,
),
)
print(result.final_output)
return str(result.final_output)
stream_result = Runner.run_streamed(
agent,
prompt,
run_config=RunConfig(
sandbox=SandboxRunConfig(session=session),
workflow_name=workflow_name,
),
)
saw_text_delta = False
saw_any_text = 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)
saw_any_text = True
continue
if event.type != "run_item_stream_event":
continue
banner = _stream_event_banner(event.name)
if banner is None:
continue
if saw_text_delta:
print()
saw_text_delta = False
print(f"{banner}: {tool_call_name(event.item.raw_item) or 'tool'}", flush=True)
if saw_text_delta:
print()
if not saw_any_text:
print(stream_result.final_output)
return str(stream_result.final_output)
async def _start_http_server(session: Any, *, port: int, workspace_root: str) -> None:
command = (
"python -m http.server "
f"{port} --bind 0.0.0.0 --directory {workspace_root} "
f"> {HTTP_LOG_PATH.as_posix()} 2>&1 &"
)
result = await session.exec(command, shell=True, timeout=10)
if not result.ok():
raise RuntimeError(result.stderr.decode("utf-8", errors="replace"))
def _build_endpoint_url(endpoint: Any) -> str:
scheme = "https" if endpoint.tls else "http"
port = endpoint.port
host = endpoint.host
if (scheme == "https" and port == 443) or (scheme == "http" and port == 80):
return f"{scheme}://{host}/"
return f"{scheme}://{host}:{port}/"
async def _fetch_text(url: str, *, timeout_s: float) -> str:
def _fetch() -> str:
with urllib.request.urlopen(url, timeout=timeout_s) as response:
payload = response.read()
if isinstance(payload, bytes):
return payload.decode("utf-8", errors="replace")
return str(payload)
return await asyncio.to_thread(_fetch)
async def _poll_http_preview(url: str, *, expected_substring: str, timeout_s: float) -> str:
deadline = time.monotonic() + timeout_s
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
body = await _fetch_text(url, timeout_s=5.0)
if expected_substring in body:
return body
except (urllib.error.URLError, TimeoutError) as exc:
last_error = exc
await asyncio.sleep(2)
if last_error is not None:
raise RuntimeError(f"HTTP preview never became ready: {last_error}") from last_error
raise RuntimeError("HTTP preview never returned the expected content.")
async def _preflight_public_resources(client: RunloopSandboxClient) -> dict[str, object]:
blueprints = await _collect_async_items(
await client.platform.blueprints.list_public(limit=3),
limit=3,
)
benchmarks = await _collect_async_items(
await client.platform.benchmarks.list_public(limit=3),
limit=3,
)
blueprint_summaries = [
_summarize_resource(item, ("id", "name", "status")) for item in blueprints
]
benchmark_summaries = [
_summarize_resource(item, ("id", "name", "description")) for item in benchmarks
]
if blueprint_summaries:
print("public blueprints:")
for summary in blueprint_summaries:
print(f" - {summary}")
else:
print("public blueprints: none returned")
if benchmark_summaries:
print("public benchmarks:")
for summary in benchmark_summaries:
print(f" - {summary}")
else:
print("public benchmarks: none returned")
return {
"public_blueprints": blueprint_summaries,
"public_benchmarks": benchmark_summaries,
}
async def _query_runloop_secret(
client: RunloopSandboxClient,
*,
name: str,
) -> RunloopResourceQueryResult:
try:
secret = cast(Any, await client.platform.secrets.get(name))
except Exception as exc:
if _is_not_found(exc):
return RunloopResourceQueryResult(resource_type="secret", name=name, found=False)
raise
return RunloopResourceQueryResult(
resource_type="secret",
name=name,
found=True,
id=cast(str | None, getattr(secret, "id", None)),
)
async def _query_runloop_network_policy(
client: RunloopSandboxClient,
*,
name: str,
) -> RunloopResourceQueryResult:
policies = await _collect_maybe_async_items(
await client.platform.network_policies.list(name=name, limit=10),
limit=10,
)
for policy in policies:
if getattr(policy, "name", None) != name:
continue
info = cast(
Any, await client.platform.network_policies.get(cast(str, policy.id)).get_info()
)
return RunloopResourceQueryResult(
resource_type="network_policy",
name=name,
found=True,
id=cast(str | None, getattr(policy, "id", None)),
description=cast(str | None, getattr(info, "description", None)),
)
return RunloopResourceQueryResult(resource_type="network_policy", name=name, found=False)
def _build_resource_query_tools(
client: RunloopSandboxClient,
*,
managed_secret_name: str,
network_policy_name: str,
) -> tuple[list[Any], dict[str, RunloopResourceQueryResult]]:
query_results: dict[str, RunloopResourceQueryResult] = {}
@function_tool
async def query_runloop_secret(name: str) -> RunloopResourceQueryResult:
"""Query whether a Runloop secret exists by name and return non-sensitive metadata."""
result = await _query_runloop_secret(client, name=name)
query_results["secret"] = result
return result
@function_tool
async def query_runloop_network_policy(name: str) -> RunloopResourceQueryResult:
"""Query whether a Runloop network policy exists by name and return basic metadata."""
result = await _query_runloop_network_policy(client, name=name)
query_results["network_policy"] = result
return result
tools = [query_runloop_secret, query_runloop_network_policy]
_ = (managed_secret_name, network_policy_name)
return tools, query_results
async def _run_resource_query_phase(
client: RunloopSandboxClient,
*,
model: str,
stream: bool,
managed_secret_name: str,
network_policy_name: str,
) -> tuple[dict[str, RunloopResourceQueryResult], str]:
tools, query_results = _build_resource_query_tools(
client,
managed_secret_name=managed_secret_name,
network_policy_name=network_policy_name,
)
query_agent = Agent(
name="Runloop Resource Discovery Guide",
model=model,
instructions=(
"Use both query tools before answering. You are checking whether the persistent "
"Runloop example resources already exist before any create step.\n\n"
f"1. Call `query_runloop_secret` with `{managed_secret_name}`.\n"
f"2. Call `query_runloop_network_policy` with `{network_policy_name}`.\n"
"3. Final answer in 2 short sentences stating whether each resource already exists."
),
tools=tools,
model_settings=ModelSettings(tool_choice="required"),
)
prompt = (
"Check whether the persistent Runloop secret and network policy for this example already "
"exist before the script attempts any create or reuse step."
)
output = await _run_plain_agent(
agent=query_agent,
prompt=prompt,
workflow_name="Runloop resource query example",
stream=stream,
)
if "secret" not in query_results or "network_policy" not in query_results:
raise RuntimeError("The query agent did not call both Runloop resource query tools.")
return query_results, output
async def _bootstrap_persistent_resources(
client: RunloopSandboxClient,
*,
managed_secret_name: str,
managed_secret_value: str,
network_policy_name: str,
network_policy_id_override: str | None,
query_results: dict[str, RunloopResourceQueryResult],
axon_name: str | None,
) -> dict[str, object]:
secret_query = query_results["secret"]
policy_query = query_results["network_policy"]
bootstrap: dict[str, object] = {
"managed_secret_value": managed_secret_value,
"secret": RunloopResourceBootstrapResult(
resource_type="secret",
name=managed_secret_name,
action="reused" if secret_query.found else "created",
id=secret_query.id,
found_before_bootstrap=secret_query.found,
),
"network_policy": RunloopResourceBootstrapResult(
resource_type="network_policy",
name=network_policy_name,
action="override"
if network_policy_id_override
else ("reused" if policy_query.found else "created"),
id=network_policy_id_override or policy_query.id,
found_before_bootstrap=policy_query.found,
),
"axon_id": None,
"axon_name": axon_name,
}
secret_result = cast(RunloopResourceBootstrapResult, bootstrap["secret"])
if not secret_query.found:
created_secret = cast(
Any,
await client.platform.secrets.create(
name=managed_secret_name, value=managed_secret_value
),
)
secret_result.id = cast(str | None, getattr(created_secret, "id", None))
print(
"persistent secret bootstrap:",
secret_result.model_dump(mode="json"),
)
policy_result = cast(RunloopResourceBootstrapResult, bootstrap["network_policy"])
if network_policy_id_override is None and not policy_query.found:
try:
created_policy = cast(
Any,
await client.platform.network_policies.create(
name=network_policy_name,
allow_all=True,
description="Persistent network policy for the Runloop capabilities example.",
),
)
except Exception as exc:
if not _is_conflict(exc):
raise
policy_result.action = "reused"
policy_result.found_before_bootstrap = True
refreshed_policy = await _query_runloop_network_policy(client, name=network_policy_name)
policy_result.id = refreshed_policy.id
else:
policy_result.id = cast(str | None, getattr(created_policy, "id", None))
print(
"persistent network policy bootstrap:",
policy_result.model_dump(mode="json"),
)
if axon_name is not None:
axon = cast(Any, await client.platform.axons.create(name=axon_name))
await client.platform.axons.query_sql(
cast(str, axon.id),
sql="CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL)",
)
await client.platform.axons.batch_sql(
cast(str, axon.id),
statements=[
{"sql": "INSERT INTO events (kind) VALUES (?)", "params": ["capabilities"]},
{"sql": "INSERT INTO events (kind) VALUES (?)", "params": ["agent_guided"]},
],
)
query_result = cast(
Any,
await client.platform.axons.query_sql(
cast(str, axon.id),
sql="SELECT COUNT(*) AS total_events FROM events",
),
)
publish_result = cast(
Any,
await client.platform.axons.publish(
cast(str, axon.id),
event_type="capabilities_example",
origin="AGENT_EVENT",
payload=json.dumps({"axon_name": axon_name}),
source="openai-agents-python",
),
)
bootstrap["axon_id"] = cast(str, axon.id)
print(
"axon demo created:",
{
"id": cast(str, axon.id),
"name": axon_name,
"rows": query_result.rows,
"published": getattr(publish_result, "published", None),
},
)
return bootstrap
def _optional_gateways(args: argparse.Namespace) -> dict[str, RunloopGatewaySpec]:
if not (args.gateway_env_var and args.gateway_name and args.gateway_secret_name):
return {}
return {
args.gateway_env_var: RunloopGatewaySpec(
gateway=args.gateway_name,
secret=args.gateway_secret_name,
)
}
def _optional_mcp(args: argparse.Namespace) -> dict[str, RunloopMcpSpec]:
if not (args.mcp_env_var and args.mcp_config and args.mcp_secret_name):
return {}
return {
args.mcp_env_var: RunloopMcpSpec(
mcp_config=args.mcp_config,
secret=args.mcp_secret_name,
)
}
async def main(args: argparse.Namespace) -> None:
_require_env("OPENAI_API_KEY")
_require_env("RUNLOOP_API_KEY")
workspace_root = (
DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT if args.root else DEFAULT_RUNLOOP_WORKSPACE_ROOT
)
run_id = _run_id()
metadata = {
"example": "runloop-capabilities",
"run_id": run_id,
}
client = RunloopSandboxClient()
session = None
resumed = None
session_closed = False
resumed_closed = False
try:
_phase("Public Resource Discovery")
public_context = await _preflight_public_resources(client)
_phase("Agent Resource Discovery")
query_results, query_agent_output = await _run_resource_query_phase(
client,
model=args.model,
stream=args.stream,
managed_secret_name=PERSISTENT_SECRET_NAME,
network_policy_name=PERSISTENT_NETWORK_POLICY_NAME,
)
print(
"resource query results:",
{key: value.model_dump(mode="json") for key, value in query_results.items()},
)
_phase("Persistent Resource Bootstrap")
axon_name = f"{EXAMPLE_RESOURCE_SLUG}-axon-{run_id}" if args.with_axon_demo else None
bootstrap = await _bootstrap_persistent_resources(
client,
managed_secret_name=PERSISTENT_SECRET_NAME,
managed_secret_value=PERSISTENT_SECRET_VALUE,
network_policy_name=PERSISTENT_NETWORK_POLICY_NAME,
network_policy_id_override=args.network_policy_id,
query_results=query_results,
axon_name=axon_name,
)
secret_bootstrap = cast(RunloopResourceBootstrapResult, bootstrap["secret"])
network_policy_bootstrap = cast(RunloopResourceBootstrapResult, bootstrap["network_policy"])
network_policy_id = network_policy_bootstrap.id
context = {
"example_slug": EXAMPLE_RESOURCE_SLUG,
"workspace_root": workspace_root,
"requested_blueprint_name": args.blueprint_name,
"public_resources": public_context,
"resource_query_agent_output": query_agent_output,
"resource_queries": {
key: value.model_dump(mode="json") for key, value in query_results.items()
},
"resource_bootstrap": {
"secret": secret_bootstrap.model_dump(mode="json"),
"network_policy": network_policy_bootstrap.model_dump(mode="json"),
"axon_id": bootstrap["axon_id"],
"axon_name": bootstrap["axon_name"],
},
"managed_secret_env_var": PERSISTENT_SECRET_NAME,
"network_policy_id": network_policy_id,
"metadata": metadata,
"gateway_bindings": sorted(_optional_gateways(args)),
"mcp_bindings": sorted(_optional_mcp(args)),
}
manifest = _build_manifest(workspace_root=workspace_root, context=context)
agent = _build_sandbox_agent(
model=args.model,
manifest=manifest,
managed_secret_name=PERSISTENT_SECRET_NAME,
)
options = RunloopSandboxClientOptions(
blueprint_name=args.blueprint_name,
pause_on_exit=True,
exposed_ports=(args.http_port,),
user_parameters=(RunloopUserParameters(username="root", uid=0) if args.root else None),
launch_parameters=RunloopLaunchParameters(
network_policy_id=network_policy_id,
resource_size_request=args.resource_size,
after_idle=RunloopAfterIdle(idle_time_seconds=300, on_idle="suspend"),
launch_commands=["echo runloop-capabilities-example"],
),
tunnel=RunloopTunnelConfig(
auth_mode="open",
http_keep_alive=True,
wake_on_http=True,
),
gateways=_optional_gateways(args),
mcp=_optional_mcp(args),
metadata=metadata,
managed_secrets={PERSISTENT_SECRET_NAME: PERSISTENT_SECRET_VALUE},
)
_phase("Sandbox Create")
session = await client.create(manifest=manifest, options=options)
await session.start()
session_state = _runloop_state(session)
print(
"session started:",
{
"devbox_id": session_state.devbox_id,
"secret_refs": session_state.secret_refs,
"metadata": session_state.metadata,
},
)
_phase("Tunnel Check")
await _write_json(
session,
RUNTIME_CONTEXT_PATH,
{
**context,
"devbox_id": session_state.devbox_id,
"secret_refs": session_state.secret_refs,
"runtime_phase": "before_tunnel_check",
},
)
await _start_http_server(session, port=args.http_port, workspace_root=workspace_root)
endpoint = await session.resolve_exposed_port(args.http_port)
preview_url = urljoin(_build_endpoint_url(endpoint), "README.md")
preview_body = await _poll_http_preview(
preview_url,
expected_substring="Runloop Capabilities Example",
timeout_s=45.0,
)
print("resolved tunnel:", preview_url)
await _write_json(
session,
RUNTIME_CONTEXT_PATH,
{
**context,
"devbox_id": session_state.devbox_id,
"secret_refs": session_state.secret_refs,
"tunnel_url": preview_url,
"http_preview_contains_readme": "Runloop Capabilities Example" in preview_body,
"runtime_phase": "before_agent_run",
},
)
_phase("Agent Verification")
await _run_sandbox_agent(
agent=agent,
prompt=args.prompt,
session=session,
workflow_name="Runloop capabilities example",
stream=args.stream,
)
proof_text = await _read_text(session, AGENT_PROOF_PATH)
print("agent proof:")
print(proof_text.rstrip())
_phase("Suspend")
await session.aclose()
session_closed = True
print("session persisted and suspended")
_phase("Resume Check")
resumed = await client.resume(session.state)
await resumed.start()
resumed_state = _runloop_state(resumed)
resumed_runtime_context = await _read_text(resumed, RUNTIME_CONTEXT_PATH)
resumed_proof_text = await _read_text(resumed, AGENT_PROOF_PATH)
print("resumed runtime context bytes:", len(resumed_runtime_context.encode("utf-8")))
print("resumed proof:")
print(resumed_proof_text.rstrip())
resumed_state.pause_on_exit = False
await resumed.aclose()
resumed_closed = True
print("resumed session cleaned up with delete semantics")
_phase("Persistent Resource Summary")
print(
"persistent resources retained:",
{
"secret": secret_bootstrap.model_dump(mode="json"),
"network_policy": network_policy_bootstrap.model_dump(mode="json"),
},
)
if bootstrap["axon_id"] is not None:
print(
"axon retained for manual cleanup:",
{
"axon_id": bootstrap["axon_id"],
"axon_name": bootstrap["axon_name"],
},
)
finally:
if resumed is not None and not resumed_closed:
try:
_runloop_state(resumed).pause_on_exit = False
await resumed.aclose()
except Exception as exc:
print(f"warning: failed to close resumed session cleanly: {exc}")
elif session is not None and not session_closed:
try:
_runloop_state(session).pause_on_exit = False
await session.aclose()
except Exception as exc:
print(f"warning: failed to close initial session cleanly: {exc}")
elif session is not None and session_closed and resumed is None:
try:
cleanup_session = await client.resume(session.state)
_runloop_state(cleanup_session).pause_on_exit = False
await cleanup_session.aclose()
except Exception as exc:
print(f"warning: failed to resume suspended session for cleanup: {exc}")
await client.close()
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
parser.add_argument(
"--prompt", default=DEFAULT_AGENT_PROMPT, help="Prompt to send to the agent."
)
parser.add_argument("--blueprint-name", default=None, help="Optional Runloop blueprint name.")
parser.add_argument(
"--resource-size",
default="MEDIUM",
choices=["X_SMALL", "SMALL", "MEDIUM", "LARGE", "X_LARGE", "XX_LARGE", "CUSTOM_SIZE"],
help="Runloop resource size request for the devbox.",
)
parser.add_argument(
"--network-policy-id",
default=None,
help="Optional Runloop network policy id override. Without this flag, the example reuses or creates the persistent example policy by name.",
)
parser.add_argument(
"--http-port",
type=int,
default=DEFAULT_HTTP_PORT,
help="Port used by the preview HTTP server.",
)
parser.add_argument(
"--root",
action="store_true",
default=False,
help="Launch the Runloop devbox as root. The workspace root becomes /root.",
)
parser.add_argument(
"--stream",
action="store_true",
default=False,
help="Stream the agent response and tool activity.",
)
parser.add_argument(
"--with-axon-demo",
action="store_true",
default=False,
help="Also create and use a temporary Axon. This leaves the Axon behind for manual cleanup.",
)
parser.add_argument(
"--gateway-env-var", default=None, help="Env var name for a gateway binding."
)
parser.add_argument(
"--gateway-name", default=None, help="Runloop gateway name for the binding."
)
parser.add_argument(
"--gateway-secret-name",
default=None,
help="Runloop secret name used by the gateway binding.",
)
parser.add_argument("--mcp-env-var", default=None, help="Env var name for an MCP binding.")
parser.add_argument(
"--mcp-config", default=None, help="Runloop MCP config name for the binding."
)
parser.add_argument(
"--mcp-secret-name",
default=None,
help="Runloop secret name used by the MCP binding.",
)
return parser
if __name__ == "__main__":
asyncio.run(main(_build_parser().parse_args()))
@@ -0,0 +1,170 @@
"""
Minimal Runloop-backed sandbox example for manual validation.
This mirrors the other cloud extension examples: it creates a tiny workspace, asks a sandboxed
agent to inspect it through one shell tool, and prints a short answer.
"""
import argparse
import asyncio
import os
import sys
from pathlib import Path
from openai.types.responses import ResponseTextDeltaEvent
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
from examples.sandbox.misc.example_support import text_manifest
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
try:
from agents.extensions.sandbox import (
DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT,
DEFAULT_RUNLOOP_WORKSPACE_ROOT,
RunloopSandboxClient,
RunloopSandboxClientOptions,
RunloopUserParameters,
)
except Exception as exc: # pragma: no cover - import path depends on optional extras
raise SystemExit(
"Runloop sandbox examples require the optional repo extra.\n"
"Install it with: uv sync --extra runloop"
) from exc
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
def _build_manifest(*, workspace_root: str) -> Manifest:
manifest = text_manifest(
{
"README.md": (
"# Runloop Demo Workspace\n\n"
"This workspace exists to validate the Runloop sandbox backend manually.\n"
),
"launch.md": (
"# Launch\n\n"
"- Customer: Contoso Logistics.\n"
"- Goal: validate the remote sandbox agent path.\n"
"- Current status: Runloop backend smoke and app-server connectivity are passing.\n"
),
"tasks.md": (
"# Tasks\n\n"
"1. Inspect the workspace files.\n"
"2. Summarize the setup and any notable status in two sentences.\n"
),
}
)
return Manifest(root=workspace_root, entries=manifest.entries)
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 main(
*,
model: str,
question: str,
pause_on_exit: bool,
blueprint_name: str | None,
root: bool,
stream: bool,
) -> None:
_require_env("OPENAI_API_KEY")
_require_env("RUNLOOP_API_KEY")
workspace_root = DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT if root else DEFAULT_RUNLOOP_WORKSPACE_ROOT
manifest = _build_manifest(workspace_root=workspace_root)
agent = SandboxAgent(
name="Runloop 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 = RunloopSandboxClient()
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=client,
options=RunloopSandboxClientOptions(
blueprint_name=blueprint_name,
pause_on_exit=pause_on_exit,
user_parameters=(RunloopUserParameters(username="root", uid=0) if root else None),
),
),
workflow_name="Runloop sandbox example",
)
try:
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.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
parser.add_argument(
"--pause-on-exit",
action="store_true",
default=False,
help="Suspend the Runloop devbox on shutdown instead of deleting it.",
)
parser.add_argument(
"--blueprint-name",
default=None,
help="Optional Runloop blueprint name to use when creating the devbox.",
)
parser.add_argument(
"--root",
action="store_true",
default=False,
help="Launch the Runloop devbox as root. The default home/workspace root becomes /root.",
)
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,
pause_on_exit=args.pause_on_exit,
blueprint_name=args.blueprint_name,
root=args.root,
stream=args.stream,
)
)
@@ -0,0 +1,98 @@
# Temporal Sandbox Agent
A conversational coding agent that runs as a durable Temporal workflow with
support for multiple sandbox backends (Daytona, Docker, E2B, local unix).
## Quickstart
**Prerequisites:** Docker (for the Docker backend) and API keys for any
cloud backends you want to use. The local and Docker sandboxes work without
any cloud provider API keys.
1. Install [just](https://just.systems/man/en/packages.html) and the
[Temporal CLI](https://docs.temporal.io/cli/setup-cli#install-the-cli)
if you don't have them already.
2. Change into the example directory:
```
cd examples/sandbox/extensions/temporal
```
3. Create a `.env` file in this directory with your API keys:
```
OPENAI_API_KEY="sk-..."
DAYTONA_API_KEY="dtn_..." # optional, for Daytona backend
E2B_API_KEY="e2b_..." # optional, for E2B backend
```
4. Start the Temporal dev server:
```
just temporal
```
5. In a second terminal, start the worker:
```
just worker
```
6. In a third terminal, start the TUI:
```
just tui
```
The `just worker` and `just tui` commands automatically install dependencies
and patch the installed `temporalio` package with vendored sandbox support.
This patch step is temporary -- the next `temporalio` release will include
sandbox support natively, at which point the vendored plugin and patch step
will be removed. Until then, running the Python scripts directly without
the patch step (i.e. skipping `just worker`/`just tui`) will fail at import
time.
## TUI commands
| Command | Description |
|--------------------|--------------------------------------------------------|
| `/switch` | Switch the current session to a different sandbox backend |
| `/fork [title]` | Fork the session onto a (possibly different) backend |
| `/title <name>` | Rename the current session |
| `/done` | Exit the TUI |
Both `/switch` and `/fork` open an interactive backend picker. When switching
to the local backend you can specify the workspace root directory.
## How it works
A single Temporal worker registers all sandbox backends via
`SandboxClientProvider`, so every backend's activities are available on one
task queue. The workflow picks which backend to target each turn by calling
`temporal_sandbox_client(name)` in its `RunConfig`.
**Files:**
- `temporal_sandbox_agent.py` -- The `AgentWorkflow` definition and worker
entrypoint. Each conversation turn calls `Runner.run()` with a
`SandboxRunConfig` that targets the active backend. The workflow is
long-lived: it idles between turns and persists indefinitely in Temporal.
- `temporal_session_manager.py` -- A singleton `SessionManagerWorkflow` that
tracks active sessions and handles create, fork, switch, and destroy
operations.
- `temporal_sandbox_tui.py` -- A [Textual](https://textual.textualize.io/) TUI
that connects to the session manager and drives conversations via signals,
updates, and queries.
- `examples/sandbox/misc/workspace_shell.py` -- A shared `Capability` that
gives the agent a shell tool for running commands in the sandbox workspace.
**Switching backends** is an in-place operation: the workflow receives a
`switch_backend` update, changes its backend and manifest, clears the
backend-specific session state, and the next turn creates a fresh session on
the new backend. The portable snapshot is preserved so workspace files carry
over.
**Forking** pauses the source workflow, snapshots its state and conversation
history, and starts a new child workflow on the chosen backend. The fork gets
an independent copy of the workspace and conversation.
@@ -0,0 +1,35 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
"""Support for using the OpenAI Agents SDK as part of Temporal workflows.
This module provides compatibility between the
`OpenAI Agents SDK <https://github.com/openai/openai-agents-python>`_ and Temporal workflows.
"""
from temporalio.contrib.openai_agents._mcp import (
StatefulMCPServerProvider,
StatelessMCPServerProvider,
)
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
from temporalio.contrib.openai_agents._temporal_openai_agents import (
OpenAIAgentsPlugin,
OpenAIPayloadConverter,
)
from temporalio.contrib.openai_agents.sandbox._sandbox_client_provider import (
SandboxClientProvider,
)
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
from . import testing, workflow
__all__ = [
"AgentsWorkflowError",
"ModelActivityParameters",
"OpenAIAgentsPlugin",
"OpenAIPayloadConverter",
"SandboxClientProvider",
"StatelessMCPServerProvider",
"StatefulMCPServerProvider",
"testing",
"workflow",
]
@@ -0,0 +1,301 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
"""A temporal activity that invokes a LLM model.
Implements mapping of OpenAI datastructures to Pydantic friendly types.
"""
import enum
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from openai import (
APIStatusError,
AsyncOpenAI,
)
from openai.types.responses.tool_param import Mcp
from temporalio import activity
from temporalio.contrib.openai_agents._heartbeat_decorator import _auto_heartbeater
from temporalio.exceptions import ApplicationError
from typing_extensions import Required, TypedDict
from agents import (
AgentOutputSchemaBase,
CodeInterpreterTool,
FileSearchTool,
FunctionTool,
Handoff,
HostedMCPTool,
ImageGenerationTool,
ModelProvider,
ModelResponse,
ModelSettings,
ModelTracing,
OpenAIProvider,
RunContextWrapper,
Tool,
TResponseInputItem,
UserError,
WebSearchTool,
)
from agents.tool import ApplyPatchTool, LocalShellTool, ShellTool, ToolSearchTool
@dataclass
class HandoffInput:
"""Data conversion friendly representation of a Handoff. Contains only the fields which are needed by the model
execution to determine what to handoff to, not the actual handoff invocation, which remains in the workflow context.
"""
tool_name: str
tool_description: str
input_json_schema: dict[str, Any]
agent_name: str
strict_json_schema: bool = True
@dataclass
class FunctionToolInput:
"""Data conversion friendly representation of a FunctionTool. Contains only the fields which are needed by the model
execution to determine what tool to call, not the actual tool invocation, which remains in the workflow context.
"""
name: str
description: str
params_json_schema: dict[str, Any]
strict_json_schema: bool = True
@dataclass
class HostedMCPToolInput:
"""Data conversion friendly representation of a HostedMCPTool. Contains only the fields which are needed by the model
execution to determine what tool to call, not the actual tool invocation, which remains in the workflow context.
"""
tool_config: Mcp
@dataclass
class ShellToolInput:
"""Data conversion friendly representation of a ShellTool. Contains only the fields which are needed by the model
execution to determine what tool to call, not the actual tool invocation, which remains in the workflow context.
"""
name: str = "shell"
environment: dict[str, Any] | None = None
@dataclass
class ApplyPatchToolInput:
"""Data conversion friendly representation of an ApplyPatchTool."""
name: str = "apply_patch"
ToolInput = (
FunctionToolInput
| FileSearchTool
| WebSearchTool
| ImageGenerationTool
| CodeInterpreterTool
| HostedMCPToolInput
| ShellToolInput
| LocalShellTool
| ApplyPatchToolInput
| ToolSearchTool
)
@dataclass
class AgentOutputSchemaInput(AgentOutputSchemaBase):
"""Data conversion friendly representation of AgentOutputSchema."""
output_type_name: str | None
is_wrapped: bool
output_schema: dict[str, Any] | None
strict_json_schema: bool
def is_plain_text(self) -> bool:
"""Whether the output type is plain text (versus a JSON object)."""
return self.output_type_name is None or self.output_type_name == "str"
def is_strict_json_schema(self) -> bool:
"""Whether the JSON schema is in strict mode."""
return self.strict_json_schema
def json_schema(self) -> dict[str, Any]:
"""The JSON schema of the output type."""
if self.is_plain_text():
raise UserError("Output type is plain text, so no JSON schema is available")
if self.output_schema is None:
raise UserError("Output schema is not defined")
return self.output_schema
def validate_json(self, json_str: str) -> Any:
"""Validate the JSON string against the schema."""
raise NotImplementedError()
def name(self) -> str:
"""Get the name of the output type."""
if self.output_type_name is None:
raise ValueError("output_type_name is None")
return self.output_type_name
class ModelTracingInput(enum.IntEnum):
"""Conversion friendly representation of ModelTracing.
Needed as ModelTracing is enum.Enum instead of IntEnum
"""
DISABLED = 0
ENABLED = 1
ENABLED_WITHOUT_DATA = 2
class ActivityModelInput(TypedDict, total=False):
"""Input for the invoke_model_activity activity."""
model_name: str | None
system_instructions: str | None
input: Required[str | list[TResponseInputItem]]
model_settings: Required[ModelSettings]
tools: list[ToolInput]
output_schema: AgentOutputSchemaInput | None
handoffs: list[HandoffInput]
tracing: Required[ModelTracingInput]
previous_response_id: str | None
conversation_id: str | None
prompt: Any | None
class ModelActivity:
"""Class wrapper for model invocation activities to allow model customization. By default, we use an OpenAIProvider with retries disabled.
Disabling retries in your model of choice is recommended to allow activity retries to define the retry model.
"""
def __init__(self, model_provider: ModelProvider | None = None):
"""Initialize the activity with a model provider."""
self._model_provider = model_provider or OpenAIProvider(
openai_client=AsyncOpenAI(max_retries=0)
)
@activity.defn
@_auto_heartbeater
async def invoke_model_activity(self, input: ActivityModelInput) -> ModelResponse:
"""Activity that invokes a model with the given input."""
model = self._model_provider.get_model(input.get("model_name"))
async def empty_on_invoke_tool(_ctx: RunContextWrapper[Any], _input: str) -> str:
return ""
async def empty_on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Any:
return None
def make_tool(tool: ToolInput) -> Tool:
if isinstance(
tool,
FileSearchTool
| WebSearchTool
| ImageGenerationTool
| CodeInterpreterTool
| LocalShellTool
| ToolSearchTool,
):
return tool
elif isinstance(tool, ShellToolInput):
async def _noop_executor(*a: Any, **kw: Any) -> str:
return ""
return ShellTool(
name=tool.name,
environment=tool.environment, # type: ignore[arg-type]
executor=_noop_executor,
)
elif isinstance(tool, ApplyPatchToolInput):
# Reconstruct with a no-op editor for the model call
async def _noop_editor(*a: Any, **kw: Any) -> str:
return ""
return ApplyPatchTool(
name=tool.name,
editor=_noop_editor, # type: ignore[arg-type]
)
elif isinstance(tool, HostedMCPToolInput):
return HostedMCPTool(
tool_config=tool.tool_config,
)
elif isinstance(tool, FunctionToolInput):
return FunctionTool(
name=tool.name,
description=tool.description,
params_json_schema=tool.params_json_schema,
on_invoke_tool=empty_on_invoke_tool,
strict_json_schema=tool.strict_json_schema,
)
else:
raise UserError(f"Unknown tool type: {tool.name}") # type:ignore[reportUnreachable]
tools = [make_tool(x) for x in input.get("tools", [])]
handoffs: list[Handoff[Any, Any]] = [
Handoff(
tool_name=x.tool_name,
tool_description=x.tool_description,
input_json_schema=x.input_json_schema,
agent_name=x.agent_name,
strict_json_schema=x.strict_json_schema,
on_invoke_handoff=empty_on_invoke_handoff,
)
for x in input.get("handoffs", [])
]
try:
return await model.get_response(
system_instructions=input.get("system_instructions"),
input=input["input"],
model_settings=input["model_settings"],
tools=tools,
output_schema=input.get("output_schema"),
handoffs=handoffs,
tracing=ModelTracing(input["tracing"]),
previous_response_id=input.get("previous_response_id"),
conversation_id=input.get("conversation_id"),
prompt=input.get("prompt"),
)
except APIStatusError as e:
# Listen to server hints
retry_after = None
retry_after_ms_header = e.response.headers.get("retry-after-ms")
if retry_after_ms_header is not None:
retry_after = timedelta(milliseconds=float(retry_after_ms_header))
if retry_after is None:
retry_after_header = e.response.headers.get("retry-after")
if retry_after_header is not None:
retry_after = timedelta(seconds=float(retry_after_header))
should_retry_header = e.response.headers.get("x-should-retry")
if should_retry_header == "true":
raise e
if should_retry_header == "false":
raise ApplicationError(
"Non retryable OpenAI error",
non_retryable=True,
next_retry_delay=retry_after,
) from e
# Specifically retryable status codes
if e.response.status_code in [408, 409, 429] or e.response.status_code >= 500:
raise ApplicationError(
f"Retryable OpenAI status code: {e.response.status_code}",
non_retryable=False,
next_retry_delay=retry_after,
) from e
raise ApplicationError(
f"Non retryable OpenAI status code: {e.response.status_code}",
non_retryable=True,
next_retry_delay=retry_after,
) from e
@@ -0,0 +1,254 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
import dataclasses
from collections.abc import Awaitable, Callable
from typing import Any, Unpack
from temporalio import workflow
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
from temporalio.contrib.openai_agents._temporal_model_stub import _TemporalModelStub
from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_client import (
TemporalSandboxClient,
)
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
from agents import (
Agent,
AgentsException,
Handoff,
RunConfig,
RunContextWrapper,
RunResult,
RunResultStreaming,
RunState,
SQLiteSession,
TContext,
TResponseInputItem,
)
from agents.run import DEFAULT_AGENT_RUNNER, DEFAULT_MAX_TURNS, AgentRunner, RunOptions
from agents.sandbox import SandboxAgent
# Recursively replace models in all agents
def _convert_agent(
model_params: ModelActivityParameters,
agent: Agent[Any],
seen: dict[int, Agent] | None,
) -> Agent[Any]:
if seen is None:
seen = {}
# Short circuit if this model was already seen to prevent looping from circular handoffs
if id(agent) in seen:
return seen[id(agent)]
# This agent has already been processed in some other run
if isinstance(agent.model, _TemporalModelStub):
return agent
# Save the new version of the agent so that we can replace loops
new_agent = dataclasses.replace(agent)
seen[id(agent)] = new_agent
name = _model_name(agent)
new_handoffs: list[Agent | Handoff] = []
for handoff in agent.handoffs:
if isinstance(handoff, Agent):
new_handoffs.append(_convert_agent(model_params, handoff, seen))
elif isinstance(handoff, Handoff):
original_invoke = handoff.on_invoke_handoff
# Use default parameter to capture original_invoke by value, not reference
async def on_invoke(
context: RunContextWrapper[Any],
args: str,
invoke_func: Callable[
[RunContextWrapper[Any], str], Awaitable[Any]
] = original_invoke,
) -> Agent:
handoff_agent = await invoke_func(context, args)
return _convert_agent(model_params, handoff_agent, seen)
new_handoffs.append(dataclasses.replace(handoff, on_invoke_handoff=on_invoke))
else:
raise TypeError(f"Unknown handoff type: {type(handoff)}")
new_agent.model = _TemporalModelStub(
model_name=name,
model_params=model_params,
agent=agent,
)
new_agent.handoffs = new_handoffs
return new_agent
def _has_sandbox_agent(agent: Agent[Any], seen: set[int] | None = None) -> bool:
"""Check if any agent in the graph (following direct Agent handoffs) is a SandboxAgent."""
if seen is None:
seen = set()
if id(agent) in seen:
return False
seen.add(id(agent))
if isinstance(agent, SandboxAgent):
return True
for handoff in agent.handoffs:
if isinstance(handoff, Agent) and _has_sandbox_agent(handoff, seen):
return True
return False
class TemporalOpenAIRunner(AgentRunner):
"""Temporal Runner for OpenAI agents.
Forwards model calls to a Temporal activity.
"""
def __init__(
self,
model_params: ModelActivityParameters,
) -> None:
"""Initialize the Temporal OpenAI Runner."""
self._runner = DEFAULT_AGENT_RUNNER or AgentRunner()
self.model_params = model_params
async def run(
self,
starting_agent: Agent[TContext],
input: str | list[TResponseInputItem] | RunState[TContext],
**kwargs: Unpack[RunOptions[TContext]],
) -> RunResult:
"""Run the agent in a Temporal workflow."""
if not workflow.in_workflow():
return await self._runner.run(
starting_agent,
input,
**kwargs,
)
for t in starting_agent.tools:
if callable(t):
raise ValueError(
"Provided tool is not a tool type. If using an activity, make sure to wrap it with openai_agents.workflow.activity_as_tool."
)
if starting_agent.mcp_servers:
from temporalio.contrib.openai_agents._mcp import (
_StatefulMCPServerReference,
_StatelessMCPServerReference,
)
for s in starting_agent.mcp_servers:
if not isinstance(
s,
_StatelessMCPServerReference | _StatefulMCPServerReference,
):
raise ValueError(f"Unknown mcp_server type {type(s)} may not work durably.")
context = kwargs.get("context")
max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS)
hooks = kwargs.get("hooks")
run_config = kwargs.get("run_config")
previous_response_id = kwargs.get("previous_response_id")
session = kwargs.get("session")
if isinstance(session, SQLiteSession):
raise ValueError("Temporal workflows don't support SQLite sessions.")
if run_config is None:
run_config = RunConfig()
if run_config.model and not isinstance(run_config.model, _TemporalModelStub):
if not isinstance(run_config.model, str):
raise ValueError(
"Temporal workflows require a model name to be a string in the run config."
)
run_config = dataclasses.replace(
run_config,
model=_TemporalModelStub(
run_config.model, model_params=self.model_params, agent=None
),
)
# run_config.sandbox is global for the entire run — configure it if any agent needs it.
if _has_sandbox_agent(starting_agent) or run_config.sandbox:
if run_config.sandbox is None:
raise ValueError(
"A SandboxAgent was provided but run_config.sandbox is not configured. "
"You must set run_config.sandbox to a SandboxRunConfig. "
"For example:\n"
" from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client\n"
" run_config = RunConfig(sandbox=SandboxRunConfig(client=temporal_sandbox_client('my-backend')))"
)
elif run_config.sandbox.client is None:
raise ValueError(
"run_config.sandbox.client must be set to a temporal sandbox client. "
"Use temporalio.contrib.openai_agents.workflow.temporal_sandbox_client(name) "
"to create one, where name matches a SandboxClientProvider registered on the plugin."
)
elif not isinstance(run_config.sandbox.client, TemporalSandboxClient):
raise ValueError(
"run_config.sandbox.client must be created via "
"temporalio.contrib.openai_agents.workflow.temporal_sandbox_client(name). "
"Do not pass a raw sandbox client directly."
)
try:
return await self._runner.run(
starting_agent=_convert_agent(self.model_params, starting_agent, None),
input=input,
context=context,
max_turns=max_turns,
hooks=hooks,
run_config=run_config,
previous_response_id=previous_response_id,
session=session,
)
except AgentsException as e:
# In order for workflow failures to properly fail the workflow, we need to rewrap them in
# a Temporal error
if e.__cause__ and workflow.is_failure_exception(e.__cause__):
reraise = AgentsWorkflowError(
f"Workflow failure exception in Agents Framework: {e}"
)
reraise.__traceback__ = e.__traceback__
raise reraise from e.__cause__
else:
raise e
def run_sync(
self,
starting_agent: Agent[TContext],
input: str | list[TResponseInputItem] | RunState[TContext],
**kwargs: Any,
) -> RunResult:
"""Run the agent synchronously (not supported in Temporal workflows)."""
if not workflow.in_workflow():
return self._runner.run_sync(
starting_agent,
input,
**kwargs,
)
raise RuntimeError("Temporal workflows do not support synchronous model calls.")
def run_streamed(
self,
starting_agent: Agent[TContext],
input: str | list[TResponseInputItem] | RunState[TContext],
**kwargs: Any,
) -> RunResultStreaming:
"""Run the agent with streaming responses (not supported in Temporal workflows)."""
if not workflow.in_workflow():
return self._runner.run_streamed(
starting_agent,
input,
**kwargs,
)
raise RuntimeError("Temporal workflows do not support streaming.")
def _model_name(agent: Agent[Any]) -> str | None:
name = agent.model
if name is not None and not isinstance(name, str):
raise ValueError("Temporal workflows require a model name to be a string in the agent.")
return name
@@ -0,0 +1,206 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import Any
from openai.types.responses.response_prompt_param import ResponsePromptParam
from temporalio import workflow
from temporalio.contrib.openai_agents._invoke_model_activity import (
ActivityModelInput,
AgentOutputSchemaInput,
ApplyPatchToolInput,
FunctionToolInput,
HandoffInput,
HostedMCPToolInput,
ModelActivity,
ModelTracingInput,
ShellToolInput,
ToolInput,
)
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
from agents import (
Agent,
AgentOutputSchema,
AgentOutputSchemaBase,
CodeInterpreterTool,
FileSearchTool,
FunctionTool,
Handoff,
HostedMCPTool,
ImageGenerationTool,
Model,
ModelResponse,
ModelSettings,
ModelTracing,
Tool,
TResponseInputItem,
WebSearchTool,
)
from agents.items import TResponseStreamEvent
from agents.tool import ApplyPatchTool, LocalShellTool, ShellTool, ToolSearchTool
logger = logging.getLogger(__name__)
class _TemporalModelStub(Model): # type:ignore[reportUnusedClass]
"""A stub that allows invoking models as Temporal activities."""
def __init__(
self,
model_name: str | None,
*,
model_params: ModelActivityParameters,
agent: Agent[Any] | None,
) -> None:
self.model_name = model_name
self.model_params = model_params
self.agent = agent
async def get_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem],
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> ModelResponse:
def make_tool_info(tool: Tool) -> ToolInput:
if isinstance(
tool,
FileSearchTool
| WebSearchTool
| ImageGenerationTool
| CodeInterpreterTool
| LocalShellTool
| ToolSearchTool,
):
return tool
elif isinstance(tool, ShellTool):
return ShellToolInput(
name=tool.name,
environment=tool.environment,
)
elif isinstance(tool, ApplyPatchTool):
return ApplyPatchToolInput(name=tool.name)
elif isinstance(tool, HostedMCPTool):
return HostedMCPToolInput(tool_config=tool.tool_config)
elif isinstance(tool, FunctionTool):
return FunctionToolInput(
name=tool.name,
description=tool.description,
params_json_schema=tool.params_json_schema,
strict_json_schema=tool.strict_json_schema,
)
else:
raise ValueError(f"Unsupported tool type: {tool.name}")
tool_infos = [make_tool_info(x) for x in tools]
handoff_infos = [
HandoffInput(
tool_name=x.tool_name,
tool_description=x.tool_description,
input_json_schema=x.input_json_schema,
agent_name=x.agent_name,
strict_json_schema=x.strict_json_schema,
)
for x in handoffs
]
if output_schema is not None and not isinstance(output_schema, AgentOutputSchema):
raise TypeError(
f"Only AgentOutputSchema is supported by Temporal Model, got {type(output_schema).__name__}"
)
agent_output_schema = output_schema
output_schema_input = (
None
if agent_output_schema is None
else AgentOutputSchemaInput(
output_type_name=agent_output_schema.name(),
is_wrapped=agent_output_schema._is_wrapped,
output_schema=agent_output_schema.json_schema()
if not agent_output_schema.is_plain_text()
else None,
strict_json_schema=agent_output_schema.is_strict_json_schema(),
)
)
activity_input = ActivityModelInput(
model_name=self.model_name,
system_instructions=system_instructions,
input=input,
model_settings=model_settings,
tools=tool_infos,
output_schema=output_schema_input,
handoffs=handoff_infos,
tracing=ModelTracingInput(tracing.value),
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
if self.model_params.summary_override:
summary = (
self.model_params.summary_override
if isinstance(self.model_params.summary_override, str)
else (
self.model_params.summary_override.provide(
self.agent, system_instructions, input
)
)
)
elif self.agent:
summary = self.agent.name
else:
summary = None
if self.model_params.use_local_activity:
return await workflow.execute_local_activity_method(
ModelActivity.invoke_model_activity,
activity_input,
summary=summary,
schedule_to_close_timeout=self.model_params.schedule_to_close_timeout,
schedule_to_start_timeout=self.model_params.schedule_to_start_timeout,
start_to_close_timeout=self.model_params.start_to_close_timeout,
retry_policy=self.model_params.retry_policy,
cancellation_type=self.model_params.cancellation_type,
)
else:
return await workflow.execute_activity_method(
ModelActivity.invoke_model_activity,
activity_input,
summary=summary,
task_queue=self.model_params.task_queue,
schedule_to_close_timeout=self.model_params.schedule_to_close_timeout,
schedule_to_start_timeout=self.model_params.schedule_to_start_timeout,
start_to_close_timeout=self.model_params.start_to_close_timeout,
heartbeat_timeout=self.model_params.heartbeat_timeout,
retry_policy=self.model_params.retry_policy,
cancellation_type=self.model_params.cancellation_type,
versioning_intent=self.model_params.versioning_intent,
priority=self.model_params.priority,
)
def stream_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem],
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> AsyncIterator[TResponseStreamEvent]:
raise NotImplementedError("Temporal model doesn't support streams yet")
@@ -0,0 +1,332 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
"""Initialize Temporal OpenAI Agents overrides."""
import dataclasses
import typing
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
from contextlib import asynccontextmanager, contextmanager
from datetime import timedelta
from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
from temporalio.contrib.openai_agents._openai_runner import (
TemporalOpenAIRunner,
)
from temporalio.contrib.openai_agents._temporal_trace_provider import (
TemporalTraceProvider,
)
from temporalio.contrib.openai_agents._trace_interceptor import (
OpenAIAgentsContextPropagationInterceptor,
)
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
from temporalio.contrib.opentelemetry._tracer_provider import ReplaySafeTracerProvider
from temporalio.contrib.pydantic import (
PydanticPayloadConverter,
ToJsonOptions,
)
from temporalio.converter import (
DataConverter,
DefaultPayloadConverter,
)
from temporalio.plugin import SimplePlugin
from temporalio.worker import WorkflowRunner
from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner
from agents import ModelProvider, Trace, set_trace_provider
from agents.run import get_default_agent_runner, set_default_agent_runner
from agents.tracing import get_trace_provider
from agents.tracing.provider import DefaultTraceProvider
if typing.TYPE_CHECKING:
from temporalio.contrib.openai_agents import (
SandboxClientProvider,
StatefulMCPServerProvider,
StatelessMCPServerProvider,
)
@contextmanager
def _set_open_ai_agent_temporal_overrides(
model_params: ModelActivityParameters,
start_spans_in_replay: bool = False,
):
previous_runner = get_default_agent_runner()
previous_trace_provider = get_trace_provider()
provider = TemporalTraceProvider(
start_spans_in_replay=start_spans_in_replay,
)
try:
set_default_agent_runner(TemporalOpenAIRunner(model_params))
set_trace_provider(provider)
yield provider
finally:
set_default_agent_runner(previous_runner)
set_trace_provider(previous_trace_provider or DefaultTraceProvider())
class OpenAIPayloadConverter(PydanticPayloadConverter):
"""PayloadConverter for OpenAI agents."""
def __init__(self) -> None:
"""Initialize a payload converter."""
super().__init__(ToJsonOptions(exclude_unset=True))
def _data_converter(converter: DataConverter | None) -> DataConverter:
if converter is None:
return DataConverter(payload_converter_class=OpenAIPayloadConverter)
elif converter.payload_converter_class is DefaultPayloadConverter:
return dataclasses.replace(converter, payload_converter_class=OpenAIPayloadConverter)
elif not isinstance(converter.payload_converter, OpenAIPayloadConverter):
raise ValueError("The payload converter must be of type OpenAIPayloadConverter.")
return converter
class OpenAIAgentsPlugin(SimplePlugin):
"""Temporal plugin for integrating OpenAI agents with Temporal workflows.
This plugin provides seamless integration between the OpenAI Agents SDK and
Temporal workflows. It automatically configures the necessary interceptors,
activities, and data converters to enable OpenAI agents to run within
Temporal workflows with proper tracing and model execution.
The plugin:
1. Configures the Pydantic data converter for type-safe serialization
2. Sets up tracing interceptors for OpenAI agent interactions
3. Registers model execution activities
4. Automatically registers MCP server activities and manages their lifecycles
5. Manages the OpenAI agent runtime overrides during worker execution
Example:
>>> from temporalio.client import Client
>>> from temporalio.worker import Worker
>>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters, StatelessMCPServerProvider
>>> from agents.mcp import MCPServerStdio
>>> from datetime import timedelta
>>>
>>> # Configure model parameters
>>> model_params = ModelActivityParameters(
... start_to_close_timeout=timedelta(seconds=30),
... retry_policy=RetryPolicy(maximum_attempts=3)
... )
>>>
>>> # Create MCP servers
>>> filesystem_server = StatelessMCPServerProvider(MCPServerStdio(
... name="Filesystem Server",
... params={"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]}
... ))
>>>
>>> # Create plugin with MCP servers
>>> plugin = OpenAIAgentsPlugin(
... model_params=model_params,
... mcp_server_providers=[filesystem_server]
... )
>>>
>>> # Use with client and worker
>>> client = await Client.connect(
... "localhost:7233",
... plugins=[plugin]
... )
>>> worker = Worker(
... client,
... task_queue="my-task-queue",
... workflows=[MyWorkflow],
... )
"""
def __init__(
self,
model_params: ModelActivityParameters | None = None,
model_provider: ModelProvider | None = None,
mcp_server_providers: Sequence[
"StatelessMCPServerProvider | StatefulMCPServerProvider"
] = (),
sandbox_clients: Sequence["SandboxClientProvider"] = (),
register_activities: bool = True,
add_temporal_spans: bool = True,
use_otel_instrumentation: bool = False,
) -> None:
"""Initialize the OpenAI agents plugin.
Args:
model_params: Configuration parameters for Temporal activity execution
of model calls. If None, default parameters will be used.
model_provider: Optional model provider for custom model implementations.
Useful for testing or custom model integrations.
mcp_server_providers: Sequence of MCP servers to automatically register with the worker.
Each server will be wrapped in a TemporalMCPServer if not already wrapped,
and their activities will be automatically registered with the worker.
The plugin manages the connection lifecycle of these servers.
sandbox_clients: Sequence of named sandbox client providers to register
on the worker. Each provider pairs a unique name with a real
``BaseSandboxClient`` (e.g. ``DaytonaSandboxClient``,
``UnixLocalSandboxClient``). On the workflow side, use
:func:`~temporalio.contrib.openai_agents.workflow.temporal_sandbox_client`
with the matching name to target the correct backend.
register_activities: Whether to register activities during the worker execution.
This can be disabled on some workers to allow a separation of workflows and activities
but should not be disabled on all workers, or agents will not be able to progress.
add_temporal_spans: Whether to add temporal spans to traces
use_otel_instrumentation: If set to true, enable open telemetry instrumentation.
Warning: use_otel_instrumentation is experimental and behavior may change in future versions.
Use with caution in production environments.
"""
if model_params is None:
model_params = ModelActivityParameters()
# For the default provider, we provide a default start_to_close_timeout of 60 seconds.
# Other providers will need to define their own.
if (
model_params.start_to_close_timeout is None
and model_params.schedule_to_close_timeout is None
):
if model_provider is None:
model_params.start_to_close_timeout = timedelta(seconds=60)
else:
raise ValueError(
"When configuring a custom provider, the model activity must have start_to_close_timeout or schedule_to_close_timeout"
)
# Store OTEL configuration for later setup
self._instrumented = False
self._use_otel_instrumentation = use_otel_instrumentation
# Delay activity construction until they are actually needed
def add_activities(
activities: Sequence[Callable] | None,
) -> Sequence[Callable]:
if not register_activities:
return activities or []
new_activities = [ModelActivity(model_provider).invoke_model_activity]
server_names = [server.name for server in mcp_server_providers]
if len(server_names) != len(set(server_names)):
raise ValueError(
"More than one mcp server registered with the same name. Please provide unique names."
)
for mcp_server in mcp_server_providers:
new_activities.extend(mcp_server._get_activities())
sandbox_names = [sc.name for sc in sandbox_clients]
if len(sandbox_names) != len(set(sandbox_names)):
raise ValueError(
"More than one sandbox client registered with the same name. Please provide unique names."
)
for sandbox_provider in sandbox_clients:
new_activities.extend(sandbox_provider._get_activities())
return list(activities or []) + new_activities
def workflow_runner(runner: WorkflowRunner | None) -> WorkflowRunner:
if not runner:
raise ValueError("No WorkflowRunner provided to the OpenAI plugin.")
# If in sandbox, add additional passthrough
if isinstance(runner, SandboxedWorkflowRunner):
return dataclasses.replace(
runner,
restrictions=runner.restrictions.with_passthrough_modules(
"openai", "agents", "mcp"
),
)
return runner
if not use_otel_instrumentation:
interceptor = OpenAIAgentsContextPropagationInterceptor(
add_temporal_spans=add_temporal_spans,
)
else:
from opentelemetry import trace as otel_trace
from ._otel_trace_interceptor import (
OTelOpenAIAgentsContextPropagationInterceptor,
)
provider = otel_trace.get_tracer_provider()
if not isinstance(provider, ReplaySafeTracerProvider):
raise ValueError(
"Global tracer provider must a ReplaySafeTracerProvider. Use temporalio.contrib.opentelemtry.create_trace_provider to create one."
)
interceptor = OTelOpenAIAgentsContextPropagationInterceptor(
add_temporal_spans=add_temporal_spans,
otel_id_generator=provider.id_generator(),
)
@asynccontextmanager
async def run_context() -> AsyncIterator[None]:
with self.tracing_context():
with _set_open_ai_agent_temporal_overrides(
model_params,
start_spans_in_replay=use_otel_instrumentation,
):
yield
super().__init__(
name="OpenAIAgentsPlugin",
data_converter=_data_converter,
interceptors=[interceptor],
activities=add_activities,
workflow_runner=workflow_runner,
workflow_failure_exception_types=[AgentsWorkflowError],
run_context=lambda: run_context(),
)
@contextmanager
def tracing_context(self) -> Iterator[None]:
"""Context manager for setting up OpenAI Agents tracing instrumentation.
This should be called if AgentsSDK traces and/or spans are started outside of the context of a worker.
For example:
.. code-block:: python
with env.openai_agents_plugin.tracing_context():
with trace("External trace"):
with custom_span("External span"):
workflow_handle = await new_client.start_workflow(
...
)
Yields:
Context with tracing instrumentation enabled.
"""
# Set up OTEL instrumentation if exporters are provided
otel_instrumentor = None
if self._use_otel_instrumentation and not self._instrumented:
from openinference.instrumentation.openai_agents import (
OpenAIAgentsInstrumentor,
)
from openinference.instrumentation.openai_agents._processor import (
OpenInferenceTracingProcessor,
)
from opentelemetry import trace
from opentelemetry.context import attach
from opentelemetry.trace import set_span_in_context
# Unfortunate monkey patching is needed to ensure the trace is set in context so we can propagate it.
original_on_trace_start = OpenInferenceTracingProcessor.on_trace_start
def on_trace_start(self, trace: Trace) -> None: # type: ignore[reportMissingParameterType]
original_on_trace_start(self, trace)
otel_span = self._root_spans[trace.trace_id]
attach(set_span_in_context(otel_span))
OpenInferenceTracingProcessor.on_trace_start = on_trace_start # type:ignore[method-assign]
# Set up instrumentor
otel_instrumentor = OpenAIAgentsInstrumentor()
otel_instrumentor.instrument(tracer_provider=trace.get_tracer_provider())
self._instrumented = True
try:
yield
finally:
# Clean up OTEL instrumentation
if otel_instrumentor is not None:
otel_instrumentor.uninstrument()
@@ -0,0 +1,32 @@
# TEMPORARY: Patch helpers for unreleased Temporal OpenAI Agents plugin sandbox support.
# Remove this file (and _vendored_plugin/) once temporalio ships with sandbox support baked in
# (i.e. `temporalio.contrib.openai_agents.sandbox` exists in the released package).
# Vendored plugin files checked into this repo
_plugin_src := justfile_directory() / "_vendored_plugin"
# Patch the installed temporalio package with local plugin changes
[private]
patch:
#!/usr/bin/env bash
set -euo pipefail
plugin_dst="$(uv run python -c "import temporalio, os; print(os.path.join(os.path.dirname(temporalio.__file__), 'contrib', 'openai_agents'))")"
patch_marker="$plugin_dst/.patched"
if [ ! -f "$patch_marker" ]; then
echo "Patching installed temporalio plugin from vendored source..."
cp "{{_plugin_src}}"/*.py "$plugin_dst/"
cp -r "{{_plugin_src}}/sandbox" "$plugin_dst/"
touch "$patch_marker"
echo "Done. Plugin patched with sandbox support."
fi
# Force re-patch (e.g. after updating vendored files)
[private]
repatch: unpatch patch
# Restore the installed temporalio plugin to its original state
[private]
unpatch:
@echo "Restoring original temporalio plugin..."
@uv pip install --reinstall --no-deps temporalio
@echo "Done. Plugin restored."
@@ -0,0 +1,6 @@
"""Sandbox support for Temporal OpenAI Agents.
This subpackage contains the :class:`SandboxClientProvider` (for registering
sandbox backends on the worker) and internal implementation details for
routing sandbox lifecycle and I/O operations through Temporal activities.
"""
@@ -0,0 +1,62 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
"""Public-facing provider that pairs a name with a real sandbox client."""
from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import Any
from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_activities import (
TemporalSandboxActivities,
)
from agents.sandbox.session.sandbox_client import BaseSandboxClient
class SandboxClientProvider:
"""A named sandbox client provider for Temporal workflows.
Wraps a :class:`BaseSandboxClient` with a unique name so that multiple
sandbox backends can be registered on a single Temporal worker. Each
provider gets its own set of Temporal activities whose names are prefixed
with the provider name, allowing them to coexist on the same task queue.
On the **worker side**, pass one or more providers to the plugin::
plugin = OpenAIAgentsPlugin(
sandbox_clients=[
SandboxClientProvider("daytona", DaytonaSandboxClient()),
SandboxClientProvider("local", UnixLocalSandboxClient()),
],
)
On the **workflow side**, reference a provider by name via
:func:`temporalio.contrib.openai_agents.workflow.temporal_sandbox_client`::
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=temporal_sandbox_client("daytona"),
...
),
)
Args:
name: A unique name for this sandbox backend (e.g. ``"daytona"``,
``"local"``). Must match the name used on the workflow side.
client: The real :class:`BaseSandboxClient` that performs sandbox
lifecycle and I/O operations on the worker.
"""
def __init__(self, name: str, client: BaseSandboxClient) -> None: # type: ignore[type-arg]
self._name = name
self._client = client
@property
def name(self) -> str:
"""The provider name used as an activity-name prefix."""
return self._name
def _get_activities(self) -> Sequence[Callable[..., Any]]:
"""Return all activity callables for registration with a Temporal Worker."""
return TemporalSandboxActivities(self._name, self._client).all()
@@ -0,0 +1,163 @@
"""Pydantic models for Temporal sandbox activity arguments and results.
Using ``pydantic_data_converter`` on the Temporal client means these models are
serialized/deserialized automatically. Each activity receives a single typed
model instance rather than a positional arg list.
"""
from __future__ import annotations
from typing import cast
from pydantic import BaseModel, SerializeAsAny, field_validator
from agents.sandbox import Manifest
from agents.sandbox.session.sandbox_client import BaseSandboxClientOptions
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
from agents.sandbox.snapshot import SnapshotBase, SnapshotSpecUnion
from agents.sandbox.types import User
# ---------------------------------------------------------------------------
# Shared base for all argument models that carry a session state field.
# ---------------------------------------------------------------------------
class _HasState(BaseModel):
state: SerializeAsAny[SandboxSessionState]
@field_validator("state", mode="before")
@classmethod
def _coerce_state(cls, value: object) -> SandboxSessionState:
return SandboxSessionState.parse(value)
# ---------------------------------------------------------------------------
# Argument models (workflow -> activity)
# ---------------------------------------------------------------------------
class ExecArgs(_HasState):
command: list[str]
timeout: float | None = None
shell: bool | list[str] = True
user: str | User | None = None
class ReadArgs(_HasState):
path: str
class WriteArgs(_HasState):
path: str
data: bytes
class RunningArgs(_HasState):
pass
class PersistWorkspaceArgs(_HasState):
pass
class HydrateWorkspaceArgs(_HasState):
data: bytes
class PtyExecStartArgs(_HasState):
command: list[str]
timeout: float | None = None
shell: bool | list[str] = True
user: str | User | None = None
tty: bool = False
yield_time_s: float | None = None
max_output_tokens: int | None = None
class PtyWriteStdinArgs(_HasState):
session_id: int
chars: str
yield_time_s: float | None = None
max_output_tokens: int | None = None
class StartArgs(_HasState):
pass
class StopArgs(_HasState):
pass
# ---------------------------------------------------------------------------
# Result models (activity -> workflow)
# ---------------------------------------------------------------------------
class ExecResult(BaseModel):
stdout: bytes
stderr: bytes
exit_code: int
class PtyExecUpdateResult(BaseModel):
process_id: int | None
output: bytes
exit_code: int | None
original_token_count: int | None
class ReadResult(BaseModel):
data: bytes
class RunningResult(BaseModel):
is_running: bool
class PersistWorkspaceResult(BaseModel):
data: bytes
class VoidResult(BaseModel):
pass
# ---------------------------------------------------------------------------
# Session lifecycle models (create / resume)
# ---------------------------------------------------------------------------
class CreateSessionArgs(BaseModel):
snapshot_spec: SnapshotSpecUnion | SerializeAsAny[SnapshotBase] | None = None
manifest: Manifest | None = None
client_options: SerializeAsAny[BaseSandboxClientOptions] | None = None
@field_validator("snapshot_spec", mode="before")
@classmethod
def _coerce_snapshot_spec(cls, value: object) -> SnapshotSpecUnion | SnapshotBase | None:
if value is None or isinstance(value, SnapshotBase):
return value
# SnapshotBase subclasses always carry an `id` field;
# SnapshotSpec subclasses do not. Use that to distinguish
# serialized SnapshotBase dicts from SnapshotSpecUnion dicts.
if isinstance(value, dict) and "id" in value:
return SnapshotBase.parse(value)
return cast(SnapshotSpecUnion | None, value)
@field_validator("client_options", mode="before")
@classmethod
def _coerce_client_options(cls, value: object) -> BaseSandboxClientOptions | None:
if value is None:
return None
return BaseSandboxClientOptions.parse(value)
class ResumeSessionArgs(_HasState):
pass
class SessionResult(_HasState):
"""Result of create/resume -- session state + capabilities."""
supports_pty: bool
@@ -0,0 +1,209 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
"""Worker-side Temporal activities for sandbox lifecycle and I/O operations."""
from __future__ import annotations
import io
from pathlib import Path
from typing import Any
from temporalio import activity
from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import (
CreateSessionArgs,
ExecArgs,
ExecResult as ExecResultModel,
HydrateWorkspaceArgs,
PersistWorkspaceArgs,
PersistWorkspaceResult,
PtyExecStartArgs,
PtyExecUpdateResult,
PtyWriteStdinArgs,
ReadArgs,
ReadResult,
ResumeSessionArgs,
RunningArgs,
RunningResult,
SessionResult,
StartArgs,
StopArgs,
VoidResult,
WriteArgs,
_HasState,
)
from agents.sandbox.session.sandbox_client import BaseSandboxClient
from agents.sandbox.session.sandbox_session import SandboxSession
class TemporalSandboxActivities:
"""Class-based activity set registered on the Temporal worker.
Holds a ``BaseSandboxClient`` as a dependency and caches open sessions by
``session_id`` to avoid reconnecting on every activity invocation within the
same worker process. The cache is cleared on ``sandbox_stop``. If the worker
restarts, ``_client.resume(state)`` re-establishes the connection on the
next activity invocation.
Each activity receives a single Pydantic arg model; ``pydantic_data_converter``
handles deserialization automatically.
Activity names are prefixed with the provider ``name`` so that multiple
sandbox backends can coexist on a single worker (e.g.
``"daytona-sandbox_exec"``, ``"local-sandbox_exec"``).
"""
def __init__(self, name: str, client: BaseSandboxClient) -> None: # type: ignore[type-arg]
self._name = name
self._client = client
self._sessions: dict[str, SandboxSession] = {}
async def _session(self, args: _HasState) -> SandboxSession:
key = str(args.state.session_id)
if key not in self._sessions:
self._sessions[key] = await self._client.resume(args.state)
return self._sessions[key]
def all(self) -> list[Any]:
"""Return all activity callables for registration with a Temporal ``Worker``.
Each activity is a closure that captures ``self`` and is decorated with
a provider-prefixed name so that multiple ``TemporalSandboxActivities``
instances (one per sandbox backend) can be registered on the same worker.
"""
prefix = self._name
# -- Client-level operations (lifecycle) --
@activity.defn(name=f"{prefix}-sandbox_client_create")
async def create_session(args: CreateSessionArgs) -> SessionResult:
session = await self._client.create(
snapshot=args.snapshot_spec,
manifest=args.manifest,
options=args.client_options,
)
self._sessions[str(session.state.session_id)] = session
return SessionResult(state=session.state, supports_pty=session.supports_pty())
@activity.defn(name=f"{prefix}-sandbox_client_resume")
async def resume_session(args: ResumeSessionArgs) -> SessionResult:
session = await self._client.resume(args.state)
self._sessions[str(session.state.session_id)] = session
return SessionResult(state=session.state, supports_pty=session.supports_pty())
@activity.defn(name=f"{prefix}-sandbox_client_delete")
async def delete_session(args: StopArgs) -> VoidResult:
session = await self._session(args)
await self._client.delete(session)
return VoidResult()
# -- Session-level operations (I/O and lifecycle) --
@activity.defn(name=f"{prefix}-sandbox_session_exec")
async def exec_(args: ExecArgs) -> ExecResultModel:
result = await (await self._session(args)).exec(
*args.command,
timeout=args.timeout,
shell=args.shell,
user=args.user,
)
return ExecResultModel(
stdout=result.stdout,
stderr=result.stderr,
exit_code=result.exit_code,
)
@activity.defn(name=f"{prefix}-sandbox_session_read")
async def read(args: ReadArgs) -> ReadResult:
handle = await (await self._session(args)).read(Path(args.path))
return ReadResult(data=handle.read())
@activity.defn(name=f"{prefix}-sandbox_session_write")
async def write(args: WriteArgs) -> VoidResult:
await (await self._session(args)).write(Path(args.path), io.BytesIO(args.data))
return VoidResult()
@activity.defn(name=f"{prefix}-sandbox_session_running")
async def running(args: RunningArgs) -> RunningResult:
return RunningResult(is_running=await (await self._session(args)).running())
@activity.defn(name=f"{prefix}-sandbox_session_persist_workspace")
async def persist_workspace(
args: PersistWorkspaceArgs,
) -> PersistWorkspaceResult:
stream = await (await self._session(args)).persist_workspace()
return PersistWorkspaceResult(data=stream.read())
@activity.defn(name=f"{prefix}-sandbox_session_hydrate_workspace")
async def hydrate_workspace(args: HydrateWorkspaceArgs) -> VoidResult:
await (await self._session(args)).hydrate_workspace(io.BytesIO(args.data))
return VoidResult()
@activity.defn(name=f"{prefix}-sandbox_session_pty_exec_start")
async def pty_exec_start(args: PtyExecStartArgs) -> PtyExecUpdateResult:
update = await (await self._session(args)).pty_exec_start(
*args.command,
timeout=args.timeout,
shell=args.shell,
user=args.user,
tty=args.tty,
yield_time_s=args.yield_time_s,
max_output_tokens=args.max_output_tokens,
)
return PtyExecUpdateResult(
process_id=update.process_id,
output=update.output,
exit_code=update.exit_code,
original_token_count=update.original_token_count,
)
@activity.defn(name=f"{prefix}-sandbox_session_pty_write_stdin")
async def pty_write_stdin(args: PtyWriteStdinArgs) -> PtyExecUpdateResult:
update = await (await self._session(args)).pty_write_stdin(
session_id=args.session_id,
chars=args.chars,
yield_time_s=args.yield_time_s,
max_output_tokens=args.max_output_tokens,
)
return PtyExecUpdateResult(
process_id=update.process_id,
output=update.output,
exit_code=update.exit_code,
original_token_count=update.original_token_count,
)
@activity.defn(name=f"{prefix}-sandbox_session_start")
async def start(args: StartArgs) -> VoidResult:
await (await self._session(args)).start()
return VoidResult()
@activity.defn(name=f"{prefix}-sandbox_session_stop")
async def session_stop(args: StopArgs) -> VoidResult:
await (await self._session(args)).stop()
return VoidResult()
@activity.defn(name=f"{prefix}-sandbox_session_shutdown")
async def session_shutdown(args: StopArgs) -> VoidResult:
key = str(args.state.session_id)
session = self._sessions.get(key)
if session is not None:
await session.shutdown()
del self._sessions[key]
return VoidResult()
return [
create_session,
resume_session,
delete_session,
exec_,
read,
write,
running,
persist_workspace,
hydrate_workspace,
pty_exec_start,
pty_write_stdin,
start,
session_stop,
session_shutdown,
]
@@ -0,0 +1,123 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
"""Temporal-aware sandbox client that dispatches lifecycle operations as activities."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from pydantic.type_adapter import TypeAdapter
from temporalio import workflow
from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import (
CreateSessionArgs,
ResumeSessionArgs,
SessionResult,
StopArgs,
VoidResult,
)
from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_session import (
TemporalSandboxSession,
)
from temporalio.workflow import ActivityConfig
from agents.sandbox import Manifest
from agents.sandbox.session.sandbox_client import (
BaseSandboxClient,
BaseSandboxClientOptions,
)
from agents.sandbox.session.sandbox_session import SandboxSession
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
from agents.sandbox.snapshot import SnapshotBase, SnapshotSpec, SnapshotSpecUnion
class TemporalSandboxClient(BaseSandboxClient[BaseSandboxClientOptions]):
"""Stateless client that dispatches all lifecycle operations as Temporal activities.
No inner client is needed -- session creation, resumption, and deletion are
all handled by activities whose names are prefixed with the provider
``name`` (e.g. ``"daytona-sandbox_create_session"``). The real
``BaseSandboxClient`` lives inside ``TemporalSandboxActivities`` on the worker.
Users should never need to instantiate this directly -- use
:func:`temporalio.contrib.openai_agents.workflow.temporal_sandbox_client`
instead.
Args:
name: The name of the :class:`SandboxClientProvider` registered on the
worker. Used as an activity-name prefix so that the correct
sandbox backend is targeted.
config: Optional activity configuration for controlling timeouts,
retries, etc. Defaults to a 5-minute ``start_to_close_timeout``.
"""
def __init__(
self,
name: str,
config: ActivityConfig | None = None,
) -> None:
self._name = name
self._config: ActivityConfig = config or ActivityConfig(
start_to_close_timeout=timedelta(minutes=5),
)
self.backend_id = name
async def create(
self,
*,
snapshot: SnapshotSpec | SnapshotBase | None = None,
manifest: Manifest | None = None,
options: BaseSandboxClientOptions,
) -> SandboxSession:
result: SessionResult = await workflow.execute_activity(
f"{self._name}-sandbox_client_create",
arg=CreateSessionArgs(
snapshot_spec=TypeAdapter(SnapshotSpecUnion).validate_python(snapshot)
if isinstance(snapshot, SnapshotSpec)
else snapshot,
manifest=manifest,
client_options=options,
),
result_type=SessionResult,
**self._config,
)
return self._wrap_session(
TemporalSandboxSession(
name=self._name,
config=self._config,
state=result.state,
supports_pty_flag=result.supports_pty,
),
# Real instrumentation runs in the activity in the real client session.
instrumentation=None,
)
async def resume(self, state: SandboxSessionState) -> SandboxSession:
result: SessionResult = await workflow.execute_activity(
f"{self._name}-sandbox_client_resume",
arg=ResumeSessionArgs(state=state),
result_type=SessionResult,
**self._config,
)
return self._wrap_session(
TemporalSandboxSession(
name=self._name,
config=self._config,
state=result.state,
supports_pty_flag=result.supports_pty,
),
# Real instrumentation runs in the activity in the real client session.
instrumentation=None,
)
async def delete(self, session: TemporalSandboxSession) -> TemporalSandboxSession: # type: ignore[override]
await workflow.execute_activity(
f"{self._name}-sandbox_client_delete",
arg=StopArgs(state=session.state),
result_type=VoidResult,
**self._config,
)
return session
def deserialize_session_state(self, payload: dict[str, Any]) -> SandboxSessionState:
return SandboxSessionState.parse(payload)
@@ -0,0 +1,227 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
"""Temporal-aware sandbox session that routes all I/O through Temporal activities."""
from __future__ import annotations
import io
from pathlib import Path
from temporalio import workflow
from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import (
ExecArgs,
ExecResult as ExecResultModel,
HydrateWorkspaceArgs,
PersistWorkspaceArgs,
PersistWorkspaceResult,
PtyExecStartArgs,
PtyExecUpdateResult,
PtyWriteStdinArgs,
ReadArgs,
ReadResult,
RunningArgs,
RunningResult,
StartArgs,
StopArgs,
VoidResult,
WriteArgs,
)
from temporalio.workflow import ActivityConfig
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.session.pty_types import PtyExecUpdate
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
from agents.sandbox.types import ExecResult, User
class TemporalSandboxSession(BaseSandboxSession):
"""A BaseSandboxSession that routes all I/O through Temporal activities.
This class is fully stateless with respect to the physical sandbox -- it
holds only the serializable ``SandboxSessionState`` and a ``supports_pty``
flag (both provided by the worker-side ``SessionResult``).
Activity names are prefixed with the provider ``name`` so that dispatches
reach the correct sandbox backend's activities on the worker.
Each activity receives a single Pydantic model instance. Because the Temporal
client is configured with ``pydantic_data_converter``, all fields are
serialized and deserialized automatically.
"""
def __init__(
self,
name: str,
config: ActivityConfig,
state: SandboxSessionState,
supports_pty_flag: bool = True,
) -> None:
self._name = name
self._config = config
self._state = state
self._supports_pty = supports_pty_flag
@property
def state(self) -> SandboxSessionState:
return self._state
@state.setter
def state(self, value: SandboxSessionState) -> None:
self._state = value
async def exec(
self,
*command: str | Path,
timeout: float | None = None,
shell: bool | list[str] = True,
user: str | User | None = None,
) -> ExecResult:
result: ExecResultModel = await workflow.execute_activity(
f"{self._name}-sandbox_session_exec",
arg=ExecArgs(
state=self.state,
command=[str(c) for c in command],
timeout=timeout,
shell=shell,
user=user,
),
result_type=ExecResultModel,
**self._config,
)
return ExecResult(stdout=result.stdout, stderr=result.stderr, exit_code=result.exit_code)
async def _exec_internal(
self,
*command: str | Path,
timeout: float | None = None,
) -> ExecResult:
raise NotImplementedError("TemporalSandboxSession overrides exec() directly")
async def read(self, path: Path) -> io.IOBase:
result: ReadResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_read",
arg=ReadArgs(state=self.state, path=str(path)),
result_type=ReadResult,
**self._config,
)
return io.BytesIO(result.data)
async def write(self, path: Path, data: io.IOBase) -> None:
_: VoidResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_write",
arg=WriteArgs(state=self.state, path=str(path), data=data.read()),
result_type=VoidResult,
**self._config,
)
async def running(self) -> bool:
result: RunningResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_running",
arg=RunningArgs(state=self.state),
result_type=RunningResult,
**self._config,
)
return result.is_running
async def shutdown(self) -> None:
_: VoidResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_shutdown",
arg=StopArgs(state=self.state),
result_type=VoidResult,
**self._config,
)
async def persist_workspace(self) -> io.IOBase:
result: PersistWorkspaceResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_persist_workspace",
arg=PersistWorkspaceArgs(state=self.state),
result_type=PersistWorkspaceResult,
**self._config,
)
return io.BytesIO(result.data)
async def hydrate_workspace(self, data: io.IOBase) -> None:
_: VoidResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_hydrate_workspace",
arg=HydrateWorkspaceArgs(state=self.state, data=data.read()),
result_type=VoidResult,
**self._config,
)
def supports_pty(self) -> bool:
return self._supports_pty
async def pty_exec_start(
self,
*command: str | Path,
timeout: float | None = None,
shell: bool | list[str] = True,
user: str | User | None = None,
tty: bool = False,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
result: PtyExecUpdateResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_pty_exec_start",
arg=PtyExecStartArgs(
state=self.state,
command=[str(c) for c in command],
timeout=timeout,
shell=shell,
user=user,
tty=tty,
yield_time_s=yield_time_s,
max_output_tokens=max_output_tokens,
),
result_type=PtyExecUpdateResult,
**self._config,
)
return PtyExecUpdate(
process_id=result.process_id,
output=result.output,
exit_code=result.exit_code,
original_token_count=result.original_token_count,
)
async def pty_write_stdin(
self,
*,
session_id: int,
chars: str,
yield_time_s: float | None = None,
max_output_tokens: int | None = None,
) -> PtyExecUpdate:
result: PtyExecUpdateResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_pty_write_stdin",
arg=PtyWriteStdinArgs(
state=self.state,
session_id=session_id,
chars=chars,
yield_time_s=yield_time_s,
max_output_tokens=max_output_tokens,
),
result_type=PtyExecUpdateResult,
**self._config,
)
return PtyExecUpdate(
process_id=result.process_id,
output=result.output,
exit_code=result.exit_code,
original_token_count=result.original_token_count,
)
async def start(self) -> None:
_: VoidResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_start",
arg=StartArgs(state=self.state),
result_type=VoidResult,
**self._config,
)
async def stop(self) -> None:
_: VoidResult = await workflow.execute_activity(
f"{self._name}-sandbox_session_stop",
arg=StopArgs(state=self.state),
result_type=VoidResult,
**self._config,
)
@@ -0,0 +1,358 @@
# vendored pre-release code; type errors are misreported due to patching
# mypy: ignore-errors
"""Workflow-specific primitives for working with the OpenAI Agents SDK in a workflow context"""
import functools
import inspect
import json
import typing
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from datetime import timedelta
from typing import Any
import nexusrpc
from temporalio import activity, workflow as temporal_workflow
from temporalio.common import Priority, RetryPolicy
from temporalio.exceptions import ApplicationError, TemporalError
from temporalio.workflow import (
ActivityCancellationType,
ActivityConfig,
VersioningIntent,
)
from agents import (
RunContextWrapper,
Tool,
)
from agents.function_schema import function_schema
from agents.tool import (
FunctionTool,
)
if typing.TYPE_CHECKING:
from agents.mcp import MCPServer
def activity_as_tool(
fn: Callable,
*,
task_queue: str | None = None,
schedule_to_close_timeout: timedelta | None = None,
schedule_to_start_timeout: timedelta | None = None,
start_to_close_timeout: timedelta | None = None,
heartbeat_timeout: timedelta | None = None,
retry_policy: RetryPolicy | None = None,
cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL,
activity_id: str | None = None,
versioning_intent: VersioningIntent | None = None,
summary: str | None = None,
priority: Priority = Priority.default,
strict_json_schema: bool = True,
) -> Tool:
"""Convert a single Temporal activity function to an OpenAI agent tool.
This function takes a Temporal activity function and converts it into an
OpenAI agent tool that can be used by the agent to execute the activity
during workflow execution. The tool will automatically handle the conversion
of inputs and outputs between the agent and the activity. Note that if you take a context,
mutation will not be persisted, as the activity may not be running in the same location.
For undocumented arguments, refer to :py:mod:`workflow` and :py:meth:`start_activity`
Args:
fn: A Temporal activity function to convert to a tool.
strict_json_schema: Whether the tool should follow a strict schema.
See https://openai.github.io/openai-agents-python/ref/tool/#agents.tool.FunctionTool.strict_json_schema
Returns:
An OpenAI agent tool that wraps the provided activity.
Raises:
ApplicationError: If the function is not properly decorated as a Temporal activity.
Example:
>>> @activity.defn
>>> def process_data(input: str) -> str:
... return f"Processed: {input}"
>>>
>>> # Create tool with custom activity options
>>> tool = activity_as_tool(
... process_data,
... start_to_close_timeout=timedelta(seconds=30),
... retry_policy=RetryPolicy(maximum_attempts=3),
... heartbeat_timeout=timedelta(seconds=10)
... )
>>> # Use tool with an OpenAI agent
"""
ret = activity._Definition.from_callable(fn)
if not ret:
raise ApplicationError(
"Bare function without tool and activity decorators is not supported",
"invalid_tool",
)
if ret.name is None:
raise ApplicationError(
"Input activity must have a name to be made into a tool",
"invalid_tool",
)
# If the provided callable has a first argument of `self`, partially apply it with the same metadata
# The actual instance will be picked up by the activity execution, the partially applied function will never actually be executed
params = list(inspect.signature(fn).parameters.keys())
if len(params) > 0 and params[0] == "self":
partial = functools.partial(fn, None)
partial.__name__ = fn.__name__
partial.__annotations__ = fn.__annotations__
setattr(
partial,
"__temporal_activity_definition",
getattr(fn, "__temporal_activity_definition"),
)
partial.__doc__ = fn.__doc__
fn = partial
schema = function_schema(fn)
async def run_activity(ctx: RunContextWrapper[Any], input: str) -> Any:
try:
json_data = json.loads(input)
except Exception as e:
raise ApplicationError(f"Invalid JSON input for tool {schema.name}: {input}") from e
# Activities don't support keyword only arguments, so we can ignore the kwargs_dict return
args, _ = schema.to_call_args(schema.params_pydantic_model(**json_data))
# Add the context to the arguments if it takes that
if schema.takes_context:
args = [ctx] + args
result = await temporal_workflow.execute_activity(
ret.name, # type: ignore
args=args,
task_queue=task_queue,
schedule_to_close_timeout=schedule_to_close_timeout,
schedule_to_start_timeout=schedule_to_start_timeout,
start_to_close_timeout=start_to_close_timeout,
heartbeat_timeout=heartbeat_timeout,
retry_policy=retry_policy,
cancellation_type=cancellation_type,
activity_id=activity_id,
versioning_intent=versioning_intent,
summary=summary or schema.description,
priority=priority,
)
try:
return str(result)
except Exception as e:
raise ToolSerializationError(
"You must return a string representation of the tool output, or something we can call str() on"
) from e
return FunctionTool(
name=schema.name,
description=schema.description or "",
params_json_schema=schema.params_json_schema,
on_invoke_tool=run_activity,
strict_json_schema=strict_json_schema,
)
def nexus_operation_as_tool(
operation: nexusrpc.Operation[Any, Any],
*,
service: type[Any],
endpoint: str,
schedule_to_close_timeout: timedelta | None = None,
strict_json_schema: bool = True,
) -> Tool:
"""Convert a Nexus operation into an OpenAI agent tool.
This function takes a Nexus operation and converts it into an
OpenAI agent tool that can be used by the agent to execute the operation
during workflow execution. The tool will automatically handle the conversion
of inputs and outputs between the agent and the operation.
Args:
operation: A Nexus operation to convert into a tool.
service: The Nexus service class that contains the operation.
endpoint: The Nexus endpoint to use for the operation.
strict_json_schema: Whether the tool should follow a strict schema
Returns:
An OpenAI agent tool that wraps the provided operation.
Example:
>>> @nexusrpc.service
... class WeatherService:
... get_weather_object_nexus_operation: nexusrpc.Operation[WeatherInput, Weather]
>>>
>>> # Create tool with custom activity options
>>> tool = nexus_operation_as_tool(
... WeatherService.get_weather_object_nexus_operation,
... service=WeatherService,
... endpoint="weather-service",
... )
>>> # Use tool with an OpenAI agent
"""
def operation_callable(input: Any): # type: ignore[reportUnusedParameter]
raise NotImplementedError("This function definition is used as a type only")
operation_callable.__annotations__ = {
"input": operation.input_type,
"return": operation.output_type,
}
operation_callable.__name__ = operation.name
schema = function_schema(operation_callable)
async def run_operation(_ctx: RunContextWrapper[Any], input: str) -> Any:
try:
json_data = json.loads(input)
except Exception as e:
raise ApplicationError(f"Invalid JSON input for tool {schema.name}: {input}") from e
nexus_client = temporal_workflow.create_nexus_client(service=service, endpoint=endpoint)
args, _ = schema.to_call_args(schema.params_pydantic_model(**json_data))
assert len(args) == 1, "Nexus operations must have exactly one argument"
[arg] = args
result = await nexus_client.execute_operation(
operation,
arg,
schedule_to_close_timeout=schedule_to_close_timeout,
)
try:
return str(result)
except Exception as e:
raise ToolSerializationError(
"You must return a string representation of the tool output, or something we can call str() on"
) from e
return FunctionTool(
name=schema.name,
description=schema.description or "",
params_json_schema=schema.params_json_schema,
on_invoke_tool=run_operation,
strict_json_schema=strict_json_schema,
)
def temporal_sandbox_client(
name: str,
config: ActivityConfig | None = None,
) -> Any:
"""Create a sandbox client reference for use in a Temporal workflow ``RunConfig``.
This returns a :class:`~agents.sandbox.session.sandbox_client.BaseSandboxClient`
that dispatches all sandbox operations as Temporal activities, targeting the
:class:`~temporalio.contrib.openai_agents.SandboxClientProvider` registered
on the worker with the matching ``name``.
Example::
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=temporal_sandbox_client("daytona"),
options=DaytonaSandboxClientOptions(...),
),
)
Args:
name: The name of the ``SandboxClientProvider`` registered on the
worker. Must match exactly.
config: Optional activity configuration for controlling timeouts,
retries, etc. Defaults to a 5-minute ``start_to_close_timeout``.
"""
from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_client import (
TemporalSandboxClient,
)
return TemporalSandboxClient(name=name, config=config)
def stateless_mcp_server(
name: str,
config: ActivityConfig | None = None,
cache_tools_list: bool = False,
factory_argument: Any | None = None,
) -> "MCPServer":
"""A stateless MCP server implementation for Temporal workflows.
This uses a TemporalMCPServer of the same name registered with the OpenAIAgents plugin to implement
durable MCP operations statelessly.
This approach is suitable for simple use cases where connection overhead is acceptable
and you don't need to maintain state between operations. It should be preferred to stateful when possible due to its
superior durability guarantees.
Args:
name: A string name for the server. Should match that provided in the plugin.
config: Optional activity configuration for MCP operation activities.
Defaults to 1-minute start-to-close timeout.
cache_tools_list: If true, the list of tools will be cached for the duration of the server
factory_argument: Optional argument to be provided to the factory when producing an MCPServer
"""
from temporalio.contrib.openai_agents._mcp import (
_StatelessMCPServerReference,
)
return _StatelessMCPServerReference(name, config, cache_tools_list, factory_argument)
def stateful_mcp_server(
name: str,
config: ActivityConfig | None = None,
server_session_config: ActivityConfig | None = None,
factory_argument: Any | None = None,
) -> AbstractAsyncContextManager["MCPServer"]:
"""A stateful MCP server implementation for Temporal workflows.
This wraps an MCP server to maintain a persistent connection throughout
the workflow execution. It creates a dedicated worker that stays connected to
the MCP server and processes operations on a dedicated task queue.
This approach is more efficient for workflows that make multiple MCP calls,
as it avoids connection overhead, but requires more resources to maintain
the persistent connection and worker.
The caller will have to handle cases where the dedicated worker fails, as Temporal is
unable to seamlessly recreate any lost state in that case.
Args:
name: A string name for the server. Should match that provided in the plugin.
config: Optional activity configuration for MCP operation activities.
Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts.
server_session_config: Optional activity configuration for the connection activity.
Defaults to 1-hour start-to-close timeout.
factory_argument: Optional argument to be provided to the factory when producing an MCPServer
"""
from temporalio.contrib.openai_agents._mcp import (
_StatefulMCPServerReference,
)
return _StatefulMCPServerReference(name, config, server_session_config, factory_argument)
class ToolSerializationError(TemporalError):
"""Error that occurs when a tool output could not be serialized.
This exception is raised when a tool (created from an activity or Nexus operation)
returns a value that cannot be properly serialized for use by the OpenAI agent.
All tool outputs must be convertible to strings for the agent to process them.
The error typically occurs when:
- A tool returns a complex object that doesn't have a meaningful string representation
- The returned object cannot be converted using str()
- Custom serialization is needed but not implemented
Example:
>>> @activity.defn
>>> def problematic_tool() -> ComplexObject:
... return ComplexObject() # This might cause ToolSerializationError
To fix this error, ensure your tool returns string-convertible values or
modify the tool to return a string representation of the result.
"""
class AgentsWorkflowError(TemporalError):
"""Error that occurs when the agents SDK raises an error which should terminate the calling workflow or update."""
@@ -0,0 +1,39 @@
"""Worker startup diagnostics."""
from __future__ import annotations
YELLOW = "\033[1;33m"
RESET = "\033[0m"
def print_backend_warnings(registered_names: set[str]) -> None:
"""Print a prominent warning banner for any unconfigured sandbox backends."""
import docker # type: ignore[import-untyped]
backend_env = {
"daytona": "DAYTONA_API_KEY",
"e2b": "E2B_API_KEY",
}
missing = {name: var for name, var in backend_env.items() if name not in registered_names}
try:
docker.from_env().ping()
except Exception:
missing["docker"] = "Docker daemon"
if not missing:
return
lines = [
"WARNING: Some sandbox backends are NOT available.",
"Missing:",
]
for name, var in sorted(missing.items()):
lines.append(f" - {name} ({var})")
lines.append("The TUI will fail if you select an unconfigured backend.")
lines.append("To use them, set the missing env vars and restart the worker.")
width = max(len(line) for line in lines) + 4
border = "!" * (width + 2)
print(f"{YELLOW}{border}{RESET}")
for line in lines:
print(f"{YELLOW}! {line:<{width - 2}} !{RESET}")
print(f"{YELLOW}{border}{RESET}")
@@ -0,0 +1,26 @@
# Temporal Sandbox Agent
set dotenv-load
set dotenv-path := ".env"
# TEMPORARY: Import patch helpers until temporalio ships with sandbox support.
# Remove this import (and patch_plugin.justfile) once the released package
# includes `temporalio.contrib.openai_agents.sandbox`.
import '_vendored_plugin/patch_plugin.justfile'
# Ensure extras are installed
[private]
sync:
@uv sync --extra temporal --extra daytona --extra e2b --extra docker 2>&1 | grep -v "^Audited\|^Resolved" || true
# Start the local Temporal dev server
temporal:
temporal server start-dev
# Start the Temporal worker
worker: sync patch
uv run --extra temporal --extra daytona --extra e2b --extra docker python temporal_sandbox_agent.py worker
# Start the TUI client
tui: sync patch
uv run --extra temporal --extra daytona --extra e2b --extra docker python temporal_sandbox_agent.py run
@@ -0,0 +1,724 @@
"""Temporal Sandbox agent example.
Runs a SandboxAgent as a durable Temporal workflow. The workflow is long-lived
and conversational: after processing each turn it idles waiting for the next
user message. Workflows persist indefinitely in Temporal. A separate session
manager workflow (``temporal_session_manager.py``) orchestrates session
creation, destruction, and discovery.
Usage
-----
Install the Temporal extra first::
uv sync --extra temporal --extra daytona
Start a local Temporal server (requires the Temporal CLI)::
temporal server start-dev
In one terminal, start the worker::
python examples/sandbox/extensions/temporal_sandbox_agent.py worker
In another terminal, start the TUI::
python examples/sandbox/extensions/temporal_sandbox_agent.py run
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os as _os
import sys
from datetime import timedelta
from enum import Enum
from pathlib import Path
from typing import Any, Literal, cast
from pydantic import BaseModel, SerializeAsAny, field_validator, model_serializer
from temporalio import workflow
from temporalio.client import Client
from temporalio.contrib.openai_agents.workflow import ( # type: ignore[attr-defined]
temporal_sandbox_client,
)
from temporalio.worker import Worker
from temporalio.worker.workflow_sandbox import (
SandboxedWorkflowRunner,
SandboxRestrictions,
)
from agents import ModelSettings, Runner
from agents.agent import Agent
from agents.extensions.sandbox import (
DaytonaSandboxClientOptions,
DaytonaSandboxSessionState,
E2BSandboxClientOptions,
E2BSandboxSessionState,
)
from agents.items import (
MessageOutputItem,
RunItem,
ToolApprovalItem,
ToolCallItem,
TResponseInputItem,
)
from agents.lifecycle import RunHooksBase
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.sandboxes import (
DockerSandboxClientOptions,
DockerSandboxSessionState,
UnixLocalSandboxClientOptions,
UnixLocalSandboxSessionState,
)
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
from agents.sandbox.snapshot import SnapshotBase
# Allow sibling and repo-root imports.
_THIS_DIR = _os.path.dirname(_os.path.abspath(__file__))
_REPO_ROOT = _os.path.abspath(_os.path.join(_THIS_DIR, "..", "..", "..", ".."))
for _p in (_THIS_DIR, _REPO_ROOT):
if _p not in sys.path:
sys.path.insert(0, _p)
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability # noqa: E402
class SandboxBackend(str, Enum):
DAYTONA = "daytona"
DOCKER = "docker"
E2B = "e2b"
LOCAL = "local"
DEFAULT_BACKEND = SandboxBackend.DAYTONA
TASK_QUEUE = "sandbox-agent-queue"
class _AlwaysSerializeType(BaseModel):
"""Base that ensures the ``type`` discriminator survives ``exclude_unset`` round-trips."""
@model_serializer(mode="wrap")
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
data: dict[str, Any] = handler(self)
data["type"] = self.type # type: ignore[attr-defined]
return data
class SwitchToLocalBackend(_AlwaysSerializeType):
"""Switch target for the local unix sandbox backend."""
type: Literal["local"] = "local"
workspace_root: str = "/workspace"
class SwitchBackendSignal(BaseModel):
"""Payload for the ``switch_backend`` signal."""
target: Literal["daytona", "docker", "e2b"] | SwitchToLocalBackend
# ---------------------------------------------------------------------------
# Workflow input / output types
# ---------------------------------------------------------------------------
class _HasSnapshot(BaseModel):
@field_validator("snapshot", mode="before", check_fields=False)
@classmethod
def _parse_snapshot(cls, v: object) -> SnapshotBase | None:
if v is None or isinstance(v, SnapshotBase):
return v
return SnapshotBase.parse(v)
class WorkflowSnapshot(_HasSnapshot):
"""Atomic snapshot of an agent workflow's forkable state."""
sandbox_session_state: (
DaytonaSandboxSessionState
| DockerSandboxSessionState
| E2BSandboxSessionState
| UnixLocalSandboxSessionState
| None
) = None
snapshot: SerializeAsAny[SnapshotBase] | None = (
None # serialized SnapshotBase for cross-backend creation
)
previous_response_id: str | None = None
history: list[dict[str, Any]] = []
class AgentRequest(_HasSnapshot):
messages: list[dict[str, Any]]
cwd: str = ""
backend: str = "daytona" # SandboxBackend value — determines client options
sandbox_session_state: (
DaytonaSandboxSessionState
| DockerSandboxSessionState
| E2BSandboxSessionState
| UnixLocalSandboxSessionState
| None
) = None
snapshot: SerializeAsAny[SnapshotBase] | None = (
None # serialized SnapshotBase for cross-backend creation
)
previous_response_id: str | None = None
history: list[dict[str, Any]] = [] # conversation history to seed (e.g. when forking)
manifest: Manifest | None = None # per-session manifest override
class AgentResponse(BaseModel):
"""Returned when the workflow is destroyed."""
pass
class ToolCallRecord(BaseModel):
"""A single tool call with its input and output for TUI display."""
tool_name: str
description: str
arguments_json: str
output: str | None = None
requires_approval: bool = False
approved: bool | None = None
class ChatResponse(BaseModel):
"""Structured response from chat() replacing the plain string."""
text: str | None = None
tool_calls: list[ToolCallRecord] = []
approval_request: ToolCallRecord | None = None
class LiveToolCall(BaseModel):
"""A tool call visible to the TUI during an active turn."""
call_id: str
tool_name: str
arguments: str
status: str = "pending" # pending | running | completed
output: str | None = None
class TurnState(BaseModel):
"""Everything the TUI needs — returned by a single query during polling."""
# idle | thinking | awaiting_approval | complete
status: str = "idle"
tool_calls: list[LiveToolCall] = []
response_text: str | None = None
approval_request: ToolCallRecord | None = None
turn_id: int = 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _format_approval_item(item: ToolApprovalItem) -> str:
"""Return a human-readable summary of a tool approval request."""
raw = item.raw_item
name = getattr(raw, "name", None) or item.tool_name or "unknown"
# Try to extract arguments for shell commands
args_str = getattr(raw, "arguments", None)
if args_str and isinstance(args_str, str):
try:
parsed = json.loads(args_str)
if name == "shell" and "commands" in parsed:
cmds = parsed["commands"]
return f"shell: {'; '.join(cmds)}"
except (json.JSONDecodeError, TypeError):
pass
return f"{name}: {args_str or '(no args)'}"
def _extract_text_from_items(items: list[RunItem]) -> str | None:
"""Pull the last assistant text from generated run items."""
for item in reversed(items):
if isinstance(item, MessageOutputItem):
raw = item.raw_item
content = getattr(raw, "content", [])
if isinstance(content, list):
for block in content:
text = getattr(block, "text", None)
if isinstance(text, str):
return text
return None
def _tool_call_records_from_items(items: list[RunItem]) -> list[ToolCallRecord]:
"""Build ToolCallRecord list from generated RunItems."""
records: list[ToolCallRecord] = []
for item in items:
if isinstance(item, ToolCallItem):
raw = item.raw_item
name = getattr(raw, "name", None) or "unknown"
args = getattr(raw, "arguments", "{}")
records.append(
ToolCallRecord(
tool_name=name,
description=f"{name}: {args}",
arguments_json=args if isinstance(args, str) else json.dumps(args),
)
)
return records
# ---------------------------------------------------------------------------
# Workflow definition
# ---------------------------------------------------------------------------
class _LiveStateHooks(RunHooksBase[Any, Agent[Any]]):
"""RunHooks that update workflow-queryable state for live TUI polling."""
def __init__(self, wf: AgentWorkflow) -> None:
self._wf = wf
async def on_llm_end(self, context, agent, response):
"""Extract tool calls from the model response and register them."""
for item in response.output:
call_id = getattr(item, "call_id", None)
if not call_id:
continue
# Standard function calls have name + arguments
name = getattr(item, "name", None)
if name:
self._wf._live_tool_calls.append(
LiveToolCall(
call_id=call_id,
tool_name=name,
arguments=getattr(item, "arguments", None) or "{}",
status="pending",
)
)
continue
# Shell tool calls have action.commands / action.command
action = getattr(item, "action", None)
if action:
cmds = getattr(action, "commands", None) or getattr(action, "command", None)
if isinstance(cmds, list):
args = json.dumps({"commands": cmds})
elif isinstance(cmds, str):
args = json.dumps({"command": cmds})
else:
args = "{}"
tool_name = getattr(item, "type", None) or "shell"
self._wf._live_tool_calls.append(
LiveToolCall(
call_id=call_id,
tool_name=tool_name,
arguments=args,
status="pending",
)
)
async def on_tool_start(self, context, agent, tool):
# Match first pending tool call (tools execute in order)
for tc in self._wf._live_tool_calls:
if tc.status == "pending":
tc.status = "running"
break
async def on_tool_end(self, context, agent, tool, result):
# Match first running tool call
for tc in self._wf._live_tool_calls:
if tc.status == "running":
tc.status = "completed"
tc.output = result[:4000] if result else None
break
@workflow.defn
class AgentWorkflow:
"""A long-lived conversational agent workflow.
The workflow persists indefinitely in Temporal, idling between TUI
sessions. It only terminates when explicitly destroyed via the
``destroy`` signal (sent by the session manager).
"""
def __init__(self) -> None:
self._pending_messages: list[str] = []
self._done = False
self._conversation_history: list[dict[str, Any]] = []
self._sandbox_session_state: (
DaytonaSandboxSessionState
| DockerSandboxSessionState
| E2BSandboxSessionState
| UnixLocalSandboxSessionState
| None
) = None
self._previous_response_id: str | None = None
self._paused: bool = False
self._pause_requested = False
self._turn_tool_calls: list[ToolCallRecord] = []
self._manifest_override: Manifest | None = None
self._backend: SandboxBackend = DEFAULT_BACKEND
self._snapshot: SnapshotBase | None = None
self._live_tool_calls: list[LiveToolCall] = []
# Turn state — queried by the TUI polling loop
self._turn_status: str = "idle"
self._turn_id: int = 0
self._last_response_text: str | None = None
self._pending_approval: ToolCallRecord | None = None
@workflow.query
def is_paused(self) -> bool:
return self._paused
@workflow.signal
async def send_message(self, msg: str) -> None:
"""Enqueue a user message. The TUI drives everything via get_turn_state polling."""
self._pending_messages.append(msg)
self._conversation_history.append({"role": "user", "content": msg})
@workflow.query
def get_history(self) -> list[dict[str, Any]]:
"""Return conversation history for TUI replay on reconnect."""
return self._conversation_history
@workflow.query
def get_snapshot_id(self) -> str | None:
"""Return just the current snapshot ID (lightweight)."""
if self._sandbox_session_state:
return self._sandbox_session_state.snapshot.id
return None
@workflow.query
def get_snapshot(self) -> WorkflowSnapshot:
"""Return an atomic snapshot of run state and conversation history."""
# Prefer the live session snapshot, but fall back to self._snapshot
# so workspace state survives a backend switch (which clears
# _sandbox_session_state) until the next turn recreates a session.
snapshot = self._snapshot
if self._sandbox_session_state:
snapshot = self._sandbox_session_state.snapshot
return WorkflowSnapshot(
sandbox_session_state=self._sandbox_session_state,
snapshot=snapshot,
previous_response_id=self._previous_response_id,
history=self._conversation_history,
)
@workflow.query
def get_turn_state(self) -> TurnState:
"""Single query that returns everything the TUI needs."""
return TurnState(
status=self._turn_status,
tool_calls=list(self._live_tool_calls),
response_text=self._last_response_text,
approval_request=self._pending_approval,
turn_id=self._turn_id,
)
@workflow.update
async def pause(self) -> None:
"""Request the workflow to pause."""
if self._paused:
return
self._pause_requested = True
await workflow.wait_condition(lambda: self._paused)
@workflow.update
async def switch_backend(self, args: SwitchBackendSignal) -> None:
"""Switch to a different sandbox backend for subsequent turns.
Clears the backend-specific session state so the next turn creates a
fresh session on the new backend. The portable snapshot is preserved
so the workspace filesystem can be carried over.
"""
match args.target:
case "daytona":
self._backend = SandboxBackend.DAYTONA
self._manifest_override = Manifest(root="/home/daytona/workspace")
case "docker":
self._backend = SandboxBackend.DOCKER
self._manifest_override = Manifest(root="/workspace")
case "e2b":
self._backend = SandboxBackend.E2B
self._manifest_override = Manifest() # E2B resolves relative to sandbox home
case SwitchToLocalBackend(workspace_root=root):
self._backend = SandboxBackend.LOCAL
self._manifest_override = Manifest(root=root)
self._sandbox_session_state = None
@workflow.signal
async def destroy(self) -> None:
"""Terminate the workflow permanently."""
self._done = True
def _resolve_sandbox_options(
self,
) -> (
DaytonaSandboxClientOptions
| DockerSandboxClientOptions
| E2BSandboxClientOptions
| UnixLocalSandboxClientOptions
):
match self._backend:
case SandboxBackend.DAYTONA:
return DaytonaSandboxClientOptions(pause_on_exit=False)
case SandboxBackend.DOCKER:
return DockerSandboxClientOptions(image="python:3.14")
case SandboxBackend.E2B:
return E2BSandboxClientOptions(sandbox_type="e2b")
case SandboxBackend.LOCAL:
return UnixLocalSandboxClientOptions()
def _resolve_manifest(self) -> Manifest:
match self._backend:
case SandboxBackend.DAYTONA:
return Manifest(root="/home/daytona/workspace")
case SandboxBackend.DOCKER:
return Manifest(root="/workspace")
case SandboxBackend.E2B:
return Manifest() # E2B resolves workspace root relative to the sandbox home
case SandboxBackend.LOCAL:
return Manifest(root="/workspace")
@workflow.run
async def run(self, request: AgentRequest) -> AgentResponse:
self._backend = SandboxBackend(request.backend)
self._snapshot = request.snapshot
if request.history:
self._conversation_history = list(request.history)
if request.sandbox_session_state:
self._sandbox_session_state = request.sandbox_session_state
if request.previous_response_id:
self._previous_response_id = request.previous_response_id
self._manifest_override = request.manifest
while not self._done:
await workflow.wait_condition(
lambda: (len(self._pending_messages) > 0 or self._pause_requested or self._done),
)
if self._pause_requested:
# Let the caller (e.g. SessionManagerWorkflow.fork_session) know
# no turn is in progress so it can safely snapshot state.
self._paused = True
self._pause_requested = False
await workflow.wait_condition(lambda: len(self._pending_messages) > 0 or self._done)
self._paused = False
if self._done:
break
user_messages = list(self._pending_messages)
self._pending_messages.clear()
self._turn_id += 1
self._turn_status = "thinking"
self._live_tool_calls = []
self._pending_approval = None
self._last_response_text = None
try:
manifest = self._manifest_override or self._resolve_manifest()
agent = self._build_agent(manifest)
await self._run_turn(agent, user_messages)
self._last_response_text = self._last_text
if self._last_text:
self._conversation_history.append(
{"role": "assistant", "content": self._last_text}
)
except Exception as e:
self._last_response_text = f"Error: {e}"
finally:
self._turn_status = "complete"
return AgentResponse()
def _build_agent(self, manifest: Manifest, model: str = "gpt-5.4") -> SandboxAgent:
"""Construct the SandboxAgent used by the workflow."""
return SandboxAgent(
name="Temporal Sandbox Agent",
model=model,
instructions=(
"You are a helpful coding assistant. Inspect the workspace and answer "
"questions. Use the shell tool to run commands. "
"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="auto"),
)
async def _run_turn(
self,
agent: SandboxAgent,
user_messages: list[str],
) -> None:
self._turn_tool_calls = []
self._last_text: str | None = None
hooks = _LiveStateHooks(self)
# Always pass fresh input — previous_response_id gives the API
# conversation context. Sandbox session state is carried via
# run_config.sandbox.session_state to preserve the sandbox across turns.
if len(user_messages) == 1:
input_arg: str | list[TResponseInputItem] = user_messages[0]
else:
input_arg = [{"role": "user", "content": m} for m in user_messages]
run_config = RunConfig(
sandbox=SandboxRunConfig(
client=temporal_sandbox_client(self._backend.value),
options=self._resolve_sandbox_options(),
# Restore sandbox session state from the previous turn if available.
session_state=self._sandbox_session_state,
snapshot=self._snapshot,
),
workflow_name="Temporal Sandbox workflow",
)
# Run the agent -- loops internally handling tool calls
result = await Runner.run(
agent,
input_arg,
run_config=run_config,
hooks=hooks,
previous_response_id=self._previous_response_id,
)
# Extract results
self._turn_tool_calls.extend(_tool_call_records_from_items(result.new_items))
self._last_text = _extract_text_from_items(result.new_items)
# Track response ID for conversation continuity and save state
# to preserve sandbox session across turns.
self._previous_response_id = result.last_response_id
# Persist sandbox session state for the next turn.
try:
state = result.to_state()
sandbox_data = state.to_json().get("sandbox", {})
session_state_data = sandbox_data.get("session_state")
if session_state_data:
self._sandbox_session_state = cast(
DaytonaSandboxSessionState | UnixLocalSandboxSessionState,
SandboxSessionState.parse(session_state_data),
)
# Keep the portable snapshot up to date so it can seed a
# fresh session after a backend switch.
self._snapshot = self._sandbox_session_state.snapshot
except Exception:
pass
# ---------------------------------------------------------------------------
# Worker entrypoint
# ---------------------------------------------------------------------------
async def run_worker() -> None:
# Imported here to avoid unnecessary passthroughs in the workflow sandbox.
import docker # type: ignore[import-untyped]
from _worker_setup import print_backend_warnings # type: ignore[import-not-found]
from temporal_session_manager import ( # type: ignore[import-not-found]
SessionManagerWorkflow,
pause_workflow,
query_workflow_snapshot,
switch_workflow_backend,
)
from temporalio.contrib.openai_agents import ( # type: ignore[attr-defined]
ModelActivityParameters,
OpenAIAgentsPlugin,
SandboxClientProvider,
)
from agents.extensions.sandbox import DaytonaSandboxClient, E2BSandboxClient
from agents.sandbox.sandboxes import DockerSandboxClient, UnixLocalSandboxClient
sandbox_clients: list[SandboxClientProvider] = [
SandboxClientProvider("local", UnixLocalSandboxClient()),
]
if _os.environ.get("DAYTONA_API_KEY"):
sandbox_clients.append(SandboxClientProvider("daytona", DaytonaSandboxClient()))
if _os.environ.get("E2B_API_KEY"):
sandbox_clients.append(SandboxClientProvider("e2b", E2BSandboxClient()))
try:
sandbox_clients.append(
SandboxClientProvider("docker", DockerSandboxClient(docker.from_env()))
)
except docker.errors.DockerException:
pass
plugin = OpenAIAgentsPlugin( # type: ignore[call-arg]
model_params=ModelActivityParameters(
start_to_close_timeout=timedelta(seconds=120),
),
sandbox_clients=sandbox_clients,
)
temporal_client = await Client.connect("localhost:7233", plugins=[plugin])
worker = Worker(
temporal_client,
task_queue=TASK_QUEUE,
workflows=[AgentWorkflow, SessionManagerWorkflow],
activities=[pause_workflow, query_workflow_snapshot, switch_workflow_backend],
workflow_runner=SandboxedWorkflowRunner(
restrictions=SandboxRestrictions.default.with_passthrough_modules(
"pydantic_core",
),
),
)
print_backend_warnings({p.name for p in sandbox_clients})
print(f"Worker started on task queue '{TASK_QUEUE}'. Press Ctrl-C to stop.")
await worker.run()
# ---------------------------------------------------------------------------
# CLI entrypoints
# ---------------------------------------------------------------------------
async def run_conversation() -> None:
"""Start the TUI -- sessions are managed entirely via Temporal."""
from temporal_sandbox_tui import ConversationApp # type: ignore[import-not-found]
app = ConversationApp(
workflow_cls=AgentWorkflow,
task_queue=TASK_QUEUE,
cwd=str(Path.cwd()),
)
await app.run_async()
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run the Sandbox agent as a multi-turn Temporal workflow."
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("worker", help="Start the Temporal worker process.")
sub.add_parser("run", help="Start an interactive agent conversation.")
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
if args.command == "worker":
asyncio.run(run_worker())
else:
asyncio.run(run_conversation())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,406 @@
# mypy: ignore-errors
# standalone example with sys.path sibling imports that mypy cannot follow
"""Temporal session manager workflow.
A long-lived singleton workflow that acts as the sole orchestrator for agent
session lifecycles. It starts and stops agent workflows, and maintains a
registry of active sessions so that TUI clients can list, resume, rename,
and destroy sessions without any filesystem persistence.
The manager is started once (well-known workflow ID ``session-manager``) and
lives forever. All lifecycle operations — create, destroy, rename, fork — go
through the manager so the registry is always consistent.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Literal
from temporalio import activity, workflow
from temporalio.exceptions import ApplicationError
from temporalio.workflow import ParentClosePolicy
with workflow.unsafe.imports_passed_through():
from pydantic import BaseModel, field_validator, model_serializer
from temporal_sandbox_agent import ( # type: ignore[import-not-found]
TASK_QUEUE,
AgentRequest,
AgentWorkflow,
SwitchBackendSignal,
SwitchToLocalBackend,
WorkflowSnapshot,
)
from temporalio.client import Client
from temporalio.contrib.openai_agents import OpenAIAgentsPlugin
from temporalio.contrib.pydantic import pydantic_data_converter
from agents import trace
from agents.sandbox import Manifest
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MANAGER_WORKFLOW_ID = "session-manager"
# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------
class DaytonaBackendConfig(BaseModel):
type: Literal["daytona"] = "daytona"
@model_serializer(mode="wrap")
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
data: dict[str, Any] = handler(self)
data["type"] = self.type
return data
class DockerBackendConfig(BaseModel):
type: Literal["docker"] = "docker"
@model_serializer(mode="wrap")
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
data: dict[str, Any] = handler(self)
data["type"] = self.type
return data
class E2BBackendConfig(BaseModel):
type: Literal["e2b"] = "e2b"
@model_serializer(mode="wrap")
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
data: dict[str, Any] = handler(self)
data["type"] = self.type
return data
class LocalBackendConfig(BaseModel):
type: Literal["local"] = "local"
workspace_root: Path | None = None
@model_serializer(mode="wrap")
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
data: dict[str, Any] = handler(self)
data["type"] = self.type
return data
@field_validator("workspace_root")
@classmethod
def _must_be_absolute(cls, v: Path | None) -> Path | None:
if v is not None and not v.is_absolute():
raise ValueError("workspace_root must be an absolute path")
return v
BackendConfig = DaytonaBackendConfig | DockerBackendConfig | E2BBackendConfig | LocalBackendConfig
class SessionInfo(BaseModel):
workflow_id: str
title: str
created_at: datetime
cwd: str = ""
backend: BackendConfig = DaytonaBackendConfig()
parent_workflow_id: str | None = None
fork_count: int = 0
snapshot_id: str | None = None
class CreateSessionRequest(BaseModel):
cwd: str
manifest: Manifest | None = None
backend: BackendConfig = DaytonaBackendConfig()
class RenameRequest(BaseModel):
workflow_id: str
title: str
class ForkSessionRequest(BaseModel):
source_workflow_id: str
title: str | None = None # defaults to "{original title} (fork #N)"
target_backend: BackendConfig | None = None
class SwitchBackendRequest(BaseModel):
source_workflow_id: str
target_backend: BackendConfig
class _SwitchWorkflowBackendArgs(BaseModel):
"""Activity args for switch_workflow_backend."""
workflow_id: str
signal: SwitchBackendSignal
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _default_manifest(
backend: BackendConfig,
) -> Manifest:
"""Return the default workspace manifest for the given backend config."""
if isinstance(backend, DaytonaBackendConfig):
return Manifest(root="/home/daytona/workspace")
if isinstance(backend, DockerBackendConfig):
return Manifest(root="/workspace")
if isinstance(backend, E2BBackendConfig):
return Manifest() # E2B resolves workspace root relative to the sandbox home
root = str(backend.workspace_root) if backend.workspace_root else "/workspace"
return Manifest(root=root)
# ---------------------------------------------------------------------------
# Activities
# ---------------------------------------------------------------------------
@activity.defn
async def pause_workflow(workflow_id: str) -> None:
"""Pause the agent workflow and wait for its session to fully stop."""
client = await Client.connect("localhost:7233", data_converter=pydantic_data_converter)
handle = client.get_workflow_handle(workflow_id)
await handle.execute_update(AgentWorkflow.pause)
@activity.defn
async def switch_workflow_backend(args: _SwitchWorkflowBackendArgs) -> None:
"""Switch the agent workflow's backend and wait for it to take effect."""
client = await Client.connect("localhost:7233", data_converter=pydantic_data_converter)
handle = client.get_workflow_handle(args.workflow_id)
await handle.execute_update(AgentWorkflow.switch_backend, args.signal)
@activity.defn
async def query_workflow_snapshot(workflow_id: str) -> WorkflowSnapshot:
"""Query the target workflow for its run state and conversation history."""
client = await Client.connect("localhost:7233", data_converter=pydantic_data_converter)
handle = client.get_workflow_handle(workflow_id)
return await handle.query(AgentWorkflow.get_snapshot)
# ---------------------------------------------------------------------------
# Workflow
# ---------------------------------------------------------------------------
@workflow.defn
class SessionManagerWorkflow:
"""Registry and orchestrator for agent sessions.
* ``create_session`` — starts a new agent child workflow and registers it.
* ``destroy_session`` — signals the agent workflow to terminate and
removes it from the registry.
* ``list_sessions`` — query returning all active sessions.
* ``rename_session`` — signal to update a session title.
"""
def __init__(self) -> None:
self._sessions: dict[str, SessionInfo] = {}
self._shutdown = False
# -- Main loop (lives forever) -----------------------------------------
@workflow.run
async def run(self) -> None:
await workflow.wait_condition(lambda: self._shutdown)
# -- Lifecycle: create & destroy (updates for request-response) ---------
@workflow.update
async def create_session(self, request: CreateSessionRequest) -> str:
"""Start a new agent workflow and register it. Returns the workflow ID."""
workflow_id = f"sandbox-agent-{workflow.uuid4()}"
manifest = request.manifest
if manifest is None:
manifest = _default_manifest(request.backend)
with OpenAIAgentsPlugin().tracing_context():
with trace("Temporal Sandbox Sandbox Agent"):
await workflow.start_child_workflow(
AgentWorkflow.run,
AgentRequest(
messages=[],
cwd=request.cwd,
backend=request.backend.type,
history=[],
manifest=manifest,
),
id=workflow_id,
task_queue=TASK_QUEUE,
parent_close_policy=ParentClosePolicy.ABANDON,
)
self._sessions[workflow_id] = SessionInfo(
workflow_id=workflow_id,
title=f"Session {workflow_id[-8:]}",
created_at=workflow.now(),
cwd=request.cwd,
backend=request.backend,
)
return workflow_id
@workflow.update
async def fork_session(self, request: ForkSessionRequest) -> str:
"""Fork an existing session into a new workflow with identical state.
Pauses the source workflow, queries its RunState and conversation
history, then starts a new child workflow seeded with that state.
When ``target_backend`` differs from the source, the sandbox session
state is not carried over (it is backend-specific), but the portable
snapshot is extracted so the new backend can create a fresh session
from the same workspace filesystem state.
"""
source = self._sessions.get(request.source_workflow_id)
if source is None:
raise ApplicationError(f"Source session {request.source_workflow_id} not found")
# Pause the source workflow so its session stops naturally
await workflow.execute_activity(
pause_workflow,
request.source_workflow_id,
start_to_close_timeout=timedelta(minutes=11),
)
# Fetch the source workflow's state via activity
workflow_snapshot: WorkflowSnapshot = await workflow.execute_activity(
query_workflow_snapshot,
request.source_workflow_id,
start_to_close_timeout=timedelta(seconds=30),
)
target_config = (
request.target_backend if request.target_backend is not None else source.backend
)
cross_backend = target_config.type != source.backend.type
# Determine fork title
source.fork_count += 1
if cross_backend:
title = request.title or f"{source.title} [{target_config.type}]"
else:
title = request.title or f"{source.title} (fork #{source.fork_count})"
# Always pass the portable snapshot so the forked session can seed
# its workspace. Never carry session_state — a fork creates an
# independent session seeded from the snapshot, not a resume of the
# source session.
snapshot = workflow_snapshot.snapshot
manifest = _default_manifest(target_config)
# Start the forked workflow with the source's run state and history
workflow_id = f"sandbox-agent-{workflow.uuid4()}"
await workflow.start_child_workflow(
AgentWorkflow.run,
AgentRequest(
messages=[],
cwd=source.cwd,
backend=target_config.type,
sandbox_session_state=None,
snapshot=snapshot,
previous_response_id=workflow_snapshot.previous_response_id,
history=workflow_snapshot.history,
manifest=manifest,
),
id=workflow_id,
task_queue=TASK_QUEUE,
parent_close_policy=ParentClosePolicy.ABANDON,
)
self._sessions[workflow_id] = SessionInfo(
workflow_id=workflow_id,
title=title,
created_at=workflow.now(),
cwd=source.cwd,
backend=target_config,
parent_workflow_id=request.source_workflow_id,
snapshot_id=workflow_snapshot.sandbox_session_state.snapshot.id
if workflow_snapshot.sandbox_session_state
else None,
)
return workflow_id
@workflow.update
async def switch_backend(self, request: SwitchBackendRequest) -> str:
"""Switch a session to a different sandbox backend in-place.
Signals the agent workflow to change its backend for subsequent turns.
The workflow stays the same — no fork, no new child workflow. The
portable snapshot is preserved so the workspace can be carried over;
the backend-specific session state is cleared by the agent workflow.
"""
source = self._sessions.get(request.source_workflow_id)
if source is None:
raise ApplicationError(f"Session {request.source_workflow_id} not found")
if isinstance(request.target_backend, LocalBackendConfig):
target: Literal["daytona", "docker", "e2b"] | SwitchToLocalBackend = (
SwitchToLocalBackend(
workspace_root=str(request.target_backend.workspace_root)
if request.target_backend.workspace_root
else "/workspace",
)
)
else:
target = request.target_backend.type
await workflow.execute_activity(
switch_workflow_backend,
_SwitchWorkflowBackendArgs(
workflow_id=request.source_workflow_id,
signal=SwitchBackendSignal(target=target),
),
start_to_close_timeout=timedelta(seconds=30),
)
source.backend = request.target_backend
return request.source_workflow_id
@workflow.update
async def destroy_session(self, workflow_id: str) -> None:
"""Signal the agent workflow to destroy and remove it from the registry."""
handle = workflow.get_external_workflow_handle(workflow_id)
await handle.signal(AgentWorkflow.destroy)
self._sessions.pop(workflow_id, None)
# -- Metadata: queries and signals --------------------------------------
@workflow.query
def list_sessions(self) -> list[SessionInfo]:
"""Return all active sessions, newest first."""
return sorted(
self._sessions.values(),
key=lambda s: s.created_at,
reverse=True,
)
@workflow.signal
async def rename_session(self, request: RenameRequest) -> None:
"""Update the title of an existing session."""
if request.workflow_id in self._sessions:
self._sessions[request.workflow_id].title = request.title
@workflow.signal
async def update_snapshot_id(self, request: RenameRequest) -> None:
"""Update the cached snapshot_id for a session.
Reuses RenameRequest where ``title`` carries the snapshot ID.
"""
if request.workflow_id in self._sessions:
self._sessions[request.workflow_id].snapshot_id = request.title
@workflow.signal
async def shutdown(self) -> None:
"""Terminate the manager workflow (rarely needed)."""
self._shutdown = True
@@ -0,0 +1,424 @@
"""
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.4", 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,
)
)
+104
View File
@@ -0,0 +1,104 @@
"""
Show how a non-sandbox agent can hand work to a sandbox agent.
The intake agent never sees a workspace directly. It hands document-heavy work
to a sandbox reviewer, and that reviewer then hands the synthesized result to a
plain account-facing writer.
"""
import argparse
import asyncio
import sys
from pathlib import Path
from agents import Agent, Runner
from agents.run import RunConfig
from agents.sandbox import SandboxAgent, SandboxRunConfig
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from examples.sandbox.misc.example_support import text_manifest
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
DEFAULT_QUESTION = (
"Review the attached onboarding packet and draft a short internal note for the account "
"executive about what to confirm before kickoff."
)
async def main(model: str, question: str) -> None:
# The manifest becomes the workspace that only the sandbox reviewer can inspect.
manifest = text_manifest(
{
"customer_background.md": (
"# Customer background\n\n"
"- Customer: Bluebird Logistics.\n"
"- Region: North America.\n"
"- New purchase: analytics workspace plus SSO.\n"
),
"kickoff_checklist.md": (
"# Kickoff checklist\n\n"
"- Security questionnaire is still in review.\n"
"- Two customer admins still need to complete access training.\n"
"- Target kickoff date is next Tuesday.\n"
),
"implementation_scope.md": (
"# Implementation scope\n\n"
"- The customer wants historical data migration for 5 years of records.\n"
"- Data engineering support is available only starting next month.\n"
),
}
)
# This final agent does not inspect files. It only rewrites reviewed facts into a note.
account_manager = Agent(
name="Account Executive Assistant",
model=model,
instructions=(
"You write concise internal updates for account teams. Convert the sandbox review "
"into a short note with a headline, the top risks, and a recommended next step."
),
)
# This sandbox agent can inspect the workspace, then hand its findings to the writer above.
sandbox_reviewer = SandboxAgent(
name="Onboarding Packet Reviewer",
model=model,
instructions=(
"You inspect onboarding documents in the sandbox, verify the facts, then hand off "
"to the account executive assistant to draft the final note. Do not answer the user "
"directly after reviewing the packet."
),
default_manifest=manifest,
handoffs=[account_manager],
capabilities=[WorkspaceShellCapability()],
)
# The starting agent is a normal agent. It only decides when to hand off into the sandbox.
intake_agent = Agent(
name="Deal Desk Intake",
model=model,
instructions=(
"You triage internal requests. If a request depends on attached documents, hand off "
"to the onboarding packet reviewer immediately."
),
handoffs=[sandbox_reviewer],
)
result = await Runner.run(
intake_agent,
question,
run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())),
)
print(result.final_output)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
args = parser.parse_args()
asyncio.run(main(args.model, args.question))
@@ -0,0 +1,86 @@
# Healthcare support
This example shows how to build a healthcare support workflow with Agents SDK using both
standard agents and a sandbox agent. The scenario is intentionally synthetic and generic: a patient
asks a billing or coverage question, the workflow checks local records, inspects policy documents in
an isolated sandbox workspace, writes support artifacts, and optionally routes one ambiguous case to
a human reviewer.
## What this example demonstrates
- **Standard agent orchestration** with a top-level support orchestrator and a benefits subagent.
- **Sandbox agents** with a mounted workspace, shell commands, a generated output folder, and
runtime-selected sandbox config.
- **Sandbox capabilities** including `Shell`, `Filesystem`, and lazy-loaded `Skills`.
- **Human-in-the-loop approvals** using an approval-gated queue-routing tool.
- **Persistent memory** with `SQLiteSession`, shared across scenario runs.
- **Structured outputs** for each specialist agent and the final case resolution.
- **Tracing** so you can inspect every model call and tool call in the OpenAI trace viewer.
- **CLI-first workflow** that can be run scenario by scenario from the repository checkout.
## Architecture
The workflow has two execution modes working together:
1. A **standard orchestrator agent** runs in the normal Agents SDK loop, calls the benefits
subagent first, then calls a sandbox agent tool, and decides whether to request a human handoff.
2. A **sandbox policy agent** runs behind `agents.sandbox`, reads the mounted case files and policy
documents, uses shell commands plus a lazily loaded skill, writes markdown artifacts into
`output/`, and returns a structured policy summary.
The local fixture data lives in `data/scenarios/*.json` and `data/fixtures/*.json`. The sandbox
policy library lives in `policies/*.md`. Generated artifacts are copied to
`.cache/healthcare_support/output/<scenario_id>/`.
## Scenarios
The built-in scenarios increase in complexity:
- `eligibility_verification_basic` checks a straightforward benefits question.
- `referral_status_check` adds a referral lookup.
- `blue_cross_pt_benefits` shows a follow-up turn that benefits from the shared SQLite memory.
- `prior_auth_confusion_ct` focuses on prior-authorization and intake-routing confusion.
- `billing_coverage_clarification` combines benefits lookup with sandbox policy search and document
generation.
- `messy_ambiguous_knee_case` triggers the human approval flow before queueing a handoff.
## Run the CLI demo
From the repository root:
```bash
uv run python examples/sandbox/healthcare_support/main.py
```
Useful options:
```bash
uv run python examples/sandbox/healthcare_support/main.py --list-scenarios
uv run python examples/sandbox/healthcare_support/main.py --scenario blue_cross_pt_benefits
uv run python examples/sandbox/healthcare_support/main.py --scenario messy_ambiguous_knee_case
uv run python examples/sandbox/healthcare_support/main.py --reset-memory
```
For unattended runs, set `EXAMPLES_INTERACTIVE_MODE=auto` to auto-answer prompts:
```bash
EXAMPLES_INTERACTIVE_MODE=auto uv run python examples/sandbox/healthcare_support/main.py --scenario messy_ambiguous_knee_case
```
## Files to read first
- [`main.py`](./main.py) runs the standalone CLI demo.
- [`workflow.py`](./workflow.py) contains the shared workflow execution logic, sandbox setup,
artifact copying, tracing, and approval resume loop.
- [`support_agents.py`](./support_agents.py) defines the orchestrator, benefits subagent, sandbox
policy agent, and memory recap agent.
- [`tools.py`](./tools.py) defines the local lookup tools and the approval-gated human handoff tool.
- [`skills/prior-auth-packet-builder/SKILL.md`](./skills/prior-auth-packet-builder/SKILL.md) is the
sandbox skill loaded at runtime.
## Notes
- This is a demo workflow, not a production healthcare system.
- All patient, payer, and policy data in this example is synthetic.
- The example loads environment defaults from the repository-root `.env` file and from this demo's
optional local `.env` file.

Some files were not shown because too many files have changed in this diff Show More