diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index e78de87f..1998fdbc 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -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
diff --git a/.github/ISSUE_TEMPLATE/model_provider.md b/.github/ISSUE_TEMPLATE/model_provider.md
index b56cb24e..a4c7a18c 100644
--- a/.github/ISSUE_TEMPLATE/model_provider.md
+++ b/.github/ISSUE_TEMPLATE/model_provider.md
@@ -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.
diff --git a/AGENTS.md b/AGENTS.md
index 7d56b604..055354b7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 5e01a1c3..00000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-Read the AGENTS.md file for instructions.
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 00000000..47dc3e3d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/README.md b/README.md
index 3fb925a2..a2c6c7c3 100644
--- a/README.md
+++ b/README.md
@@ -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_)
@@ -88,4 +106,4 @@ We also rely on the following tools to manage the project:
- [pytest](https://github.com/pytest-dev/pytest) and [Coverage.py](https://github.com/coveragepy/coveragepy)
- [MkDocs](https://github.com/squidfunk/mkdocs-material)
-We're committed to continuing to build the Agents SDK as an open source framework so others in the community can expand on our approach.
\ No newline at end of file
+We're committed to continuing to build the Agents SDK as an open source framework so others in the community can expand on our approach.
diff --git a/docs/agents.md b/docs/agents.md
index 8637005f..a7417452 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -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.
diff --git a/docs/assets/images/harness_with_compute.png b/docs/assets/images/harness_with_compute.png
new file mode 100644
index 00000000..d4e819a3
Binary files /dev/null and b/docs/assets/images/harness_with_compute.png differ
diff --git a/docs/config.md b/docs/config.md
index 3cf2aa83..98993eb4 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -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.
diff --git a/docs/index.md b/docs/index.md
index 5106c9e3..c71cabf3 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -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) |
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 89b08b41..e847d527 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -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).
diff --git a/docs/ref/sandbox.md b/docs/ref/sandbox.md
new file mode 100644
index 00000000..c7479c40
--- /dev/null
+++ b/docs/ref/sandbox.md
@@ -0,0 +1,9 @@
+# `Sandbox`
+
+::: agents.sandbox
+ options:
+ members:
+ - SandboxAgent
+ - Manifest
+ - SandboxRunConfig
+ - Capability
diff --git a/docs/ref/sandbox/capabilities/capabilities.md b/docs/ref/sandbox/capabilities/capabilities.md
new file mode 100644
index 00000000..00edb4e0
--- /dev/null
+++ b/docs/ref/sandbox/capabilities/capabilities.md
@@ -0,0 +1,6 @@
+# `Capabilities`
+
+::: agents.sandbox.capabilities.capabilities
+ options:
+ members:
+ - Capabilities
diff --git a/docs/ref/sandbox/capabilities/capability.md b/docs/ref/sandbox/capabilities/capability.md
new file mode 100644
index 00000000..475e4e66
--- /dev/null
+++ b/docs/ref/sandbox/capabilities/capability.md
@@ -0,0 +1,6 @@
+# `Capability`
+
+::: agents.sandbox.capabilities.capability
+ options:
+ members:
+ - Capability
diff --git a/docs/ref/sandbox/capabilities/compaction.md b/docs/ref/sandbox/capabilities/compaction.md
new file mode 100644
index 00000000..e8d3859e
--- /dev/null
+++ b/docs/ref/sandbox/capabilities/compaction.md
@@ -0,0 +1,10 @@
+# `Compaction`
+
+::: agents.sandbox.capabilities.compaction
+ options:
+ members:
+ - Compaction
+ - CompactionModelInfo
+ - CompactionPolicy
+ - DynamicCompactionPolicy
+ - StaticCompactionPolicy
diff --git a/docs/ref/sandbox/capabilities/filesystem.md b/docs/ref/sandbox/capabilities/filesystem.md
new file mode 100644
index 00000000..e2a9fa0d
--- /dev/null
+++ b/docs/ref/sandbox/capabilities/filesystem.md
@@ -0,0 +1,7 @@
+# `Filesystem`
+
+::: agents.sandbox.capabilities.filesystem
+ options:
+ members:
+ - Filesystem
+ - FilesystemToolSet
diff --git a/docs/ref/sandbox/capabilities/memory.md b/docs/ref/sandbox/capabilities/memory.md
new file mode 100644
index 00000000..c4cdc839
--- /dev/null
+++ b/docs/ref/sandbox/capabilities/memory.md
@@ -0,0 +1,6 @@
+# `Memory`
+
+::: agents.sandbox.capabilities.memory
+ options:
+ members:
+ - Memory
diff --git a/docs/ref/sandbox/capabilities/shell.md b/docs/ref/sandbox/capabilities/shell.md
new file mode 100644
index 00000000..4361a0e6
--- /dev/null
+++ b/docs/ref/sandbox/capabilities/shell.md
@@ -0,0 +1,7 @@
+# `Shell`
+
+::: agents.sandbox.capabilities.shell
+ options:
+ members:
+ - Shell
+ - ShellToolSet
diff --git a/docs/ref/sandbox/capabilities/skills.md b/docs/ref/sandbox/capabilities/skills.md
new file mode 100644
index 00000000..6b5c9e0e
--- /dev/null
+++ b/docs/ref/sandbox/capabilities/skills.md
@@ -0,0 +1,10 @@
+# `Skills`
+
+::: agents.sandbox.capabilities.skills
+ options:
+ members:
+ - Skills
+ - Skill
+ - SkillMetadata
+ - LazySkillSource
+ - LocalDirLazySkillSource
diff --git a/docs/ref/sandbox/entries.md b/docs/ref/sandbox/entries.md
new file mode 100644
index 00000000..47f59d9f
--- /dev/null
+++ b/docs/ref/sandbox/entries.md
@@ -0,0 +1,16 @@
+# `Workspace entries`
+
+::: agents.sandbox.entries
+ options:
+ members:
+ - Dir
+ - File
+ - GitRepo
+ - LocalDir
+ - LocalFile
+ - Mount
+ - AzureBlobMount
+ - GCSMount
+ - R2Mount
+ - S3Mount
+ - S3FilesMount
diff --git a/docs/ref/sandbox/manifest.md b/docs/ref/sandbox/manifest.md
new file mode 100644
index 00000000..bac1d319
--- /dev/null
+++ b/docs/ref/sandbox/manifest.md
@@ -0,0 +1,10 @@
+# `Manifest`
+
+::: agents.sandbox.manifest
+ options:
+ members:
+ - Manifest
+ - Environment
+ - EnvEntry
+ - EnvValue
+ - StrEnvValue
diff --git a/docs/ref/sandbox/permissions.md b/docs/ref/sandbox/permissions.md
new file mode 100644
index 00000000..8a15308c
--- /dev/null
+++ b/docs/ref/sandbox/permissions.md
@@ -0,0 +1,9 @@
+# `Permissions`
+
+::: agents.sandbox.types
+ options:
+ members:
+ - User
+ - Group
+ - Permissions
+ - FileMode
diff --git a/docs/ref/sandbox/sandbox_agent.md b/docs/ref/sandbox/sandbox_agent.md
new file mode 100644
index 00000000..b69867d6
--- /dev/null
+++ b/docs/ref/sandbox/sandbox_agent.md
@@ -0,0 +1,6 @@
+# `SandboxAgent`
+
+::: agents.sandbox.sandbox_agent
+ options:
+ members:
+ - SandboxAgent
diff --git a/docs/ref/sandbox/sandboxes/docker.md b/docs/ref/sandbox/sandboxes/docker.md
new file mode 100644
index 00000000..9c43bfbc
--- /dev/null
+++ b/docs/ref/sandbox/sandboxes/docker.md
@@ -0,0 +1,9 @@
+# `Docker sandbox`
+
+::: agents.sandbox.sandboxes.docker
+ options:
+ members:
+ - DockerSandboxClient
+ - DockerSandboxClientOptions
+ - DockerSandboxSession
+ - DockerSandboxSessionState
diff --git a/docs/ref/sandbox/sandboxes/unix_local.md b/docs/ref/sandbox/sandboxes/unix_local.md
new file mode 100644
index 00000000..914383f6
--- /dev/null
+++ b/docs/ref/sandbox/sandboxes/unix_local.md
@@ -0,0 +1,9 @@
+# `Unix local sandbox`
+
+::: agents.sandbox.sandboxes.unix_local
+ options:
+ members:
+ - UnixLocalSandboxClient
+ - UnixLocalSandboxClientOptions
+ - UnixLocalSandboxSession
+ - UnixLocalSandboxSessionState
diff --git a/docs/ref/sandbox/session/sandbox_client.md b/docs/ref/sandbox/session/sandbox_client.md
new file mode 100644
index 00000000..a988d14d
--- /dev/null
+++ b/docs/ref/sandbox/session/sandbox_client.md
@@ -0,0 +1,7 @@
+# `Sandbox clients`
+
+::: agents.sandbox.session.sandbox_client
+ options:
+ members:
+ - BaseSandboxClient
+ - BaseSandboxClientOptions
diff --git a/docs/ref/sandbox/session/sandbox_session.md b/docs/ref/sandbox/session/sandbox_session.md
new file mode 100644
index 00000000..7daf2eca
--- /dev/null
+++ b/docs/ref/sandbox/session/sandbox_session.md
@@ -0,0 +1,6 @@
+# `SandboxSession`
+
+::: agents.sandbox.session.sandbox_session
+ options:
+ members:
+ - SandboxSession
diff --git a/docs/ref/sandbox/session/sandbox_session_state.md b/docs/ref/sandbox/session/sandbox_session_state.md
new file mode 100644
index 00000000..30aea1cf
--- /dev/null
+++ b/docs/ref/sandbox/session/sandbox_session_state.md
@@ -0,0 +1,6 @@
+# `SandboxSessionState`
+
+::: agents.sandbox.session.sandbox_session_state
+ options:
+ members:
+ - SandboxSessionState
diff --git a/docs/ref/sandbox/snapshot.md b/docs/ref/sandbox/snapshot.md
new file mode 100644
index 00000000..24d2cc6a
--- /dev/null
+++ b/docs/ref/sandbox/snapshot.md
@@ -0,0 +1,11 @@
+# `SnapshotSpec`
+
+::: agents.sandbox.snapshot
+ options:
+ members:
+ - SnapshotSpec
+ - LocalSnapshotSpec
+ - RemoteSnapshotSpec
+ - LocalSnapshot
+ - RemoteSnapshot
+ - resolve_snapshot
diff --git a/docs/running_agents.md b/docs/running_agents.md
index 200a897d..c3ea406f 100644
--- a/docs/running_agents.md
+++ b/docs/running_agents.md
@@ -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.
diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md
new file mode 100644
index 00000000..683e8bc4
--- /dev/null
+++ b/docs/sandbox/clients.md
@@ -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
+
+
+
+| 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. |
+
+
+
+## Local clients
+
+For most users, start with one of these two sandbox clients:
+
+
+
+| 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) |
+
+
+
+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:
+
+
+
+| 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`. |
+
+
+
+## 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).
+
+
+
+Hosted sandbox clients expose provider-specific mount strategies. Choose the backend and mount strategy that best fit your storage provider:
+
+
+
+| 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. |
+
+
+
+The table below summarizes which remote storage entries each backend can mount directly.
+
+
+
+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.
diff --git a/docs/sandbox/guide.md b/docs/sandbox/guide.md
new file mode 100644
index 00000000..a6f9b31f
--- /dev/null
+++ b/docs/sandbox/guide.md
@@ -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.
+
+
+
+
+
+
+
+`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 full Agent + sandbox defaults"]
+ config["SandboxRunConfig client / session / resume inputs"]
+ runner["Runner prepare instructions bind capability tools"]
+ sandbox["sandbox session workspace where commands run and files change"]
+ saved["saved state / snapshot for resume or fresh-start later"]
+
+ 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
+
+
+
+| 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? |
+
+
+
+The main SDK pieces map onto those layers like this:
+
+
+
+| 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? |
+
+
+
+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:
+
+
+
+| 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. |
+
+
+
+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.
+
+
+
+| 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. |
+
+
+
+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:
+
+
+
+| 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. |
+
+
+
+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//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:
+
+
+
+| 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. |
+
+
+
+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**.
+
+
+
+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:
+
+
+
+| 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. |
+
+
+
+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:
+
+
+
+| 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. |
+
+
+
+### 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.
diff --git a/docs/sandbox/memory.md b/docs/sandbox/memory.md
new file mode 100644
index 00000000..94086fca
--- /dev/null
+++ b/docs/sandbox/memory.md
@@ -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/
+│ └── .jsonl
+└── memories/
+ ├── memory_summary.md
+ ├── MEMORY.md
+ ├── raw_memories.md (intermediate)
+ ├── phase_two_selection.json (intermediate)
+ ├── raw_memories/ (intermediate)
+ │ └── .md
+ ├── rollout_summaries/
+ │ └── _.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.
diff --git a/docs/sandbox_agents.md b/docs/sandbox_agents.md
new file mode 100644
index 00000000..e4c91074
--- /dev/null
+++ b/docs/sandbox_agents.md
@@ -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.
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
index 591a4a3e..8062ec60 100644
--- a/docs/stylesheets/extra.css
+++ b/docs/stylesheets/extra.css
@@ -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;
+}
diff --git a/examples/basic/lifecycle_example.py b/examples/basic/lifecycle_example.py
index 5ecd3a6b..51a312e0 100644
--- a/examples/basic/lifecycle_example.py
+++ b/examples/basic/lifecycle_example.py
@@ -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
diff --git a/examples/basic/stream_function_call_args.py b/examples/basic/stream_function_call_args.py
index e0480616..969c4ed4 100644
--- a/examples/basic/stream_function_call_args.py
+++ b/examples/basic/stream_function_call_args.py
@@ -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"
diff --git a/examples/run_examples.py b/examples/run_examples.py
index 79f76f92..4603477c 100644
--- a/examples/run_examples.py
+++ b/examples/run_examples.py
@@ -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"
diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md
new file mode 100644
index 00000000..a28a8cdb
--- /dev/null
+++ b/examples/sandbox/README.md
@@ -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.
diff --git a/examples/sandbox/__init__.py b/examples/sandbox/__init__.py
new file mode 100644
index 00000000..f34898d9
--- /dev/null
+++ b/examples/sandbox/__init__.py
@@ -0,0 +1 @@
+# Make the examples/sandbox directory a package for tooling consistency.
diff --git a/examples/sandbox/basic.py b/examples/sandbox/basic.py
new file mode 100644
index 00000000..21936f33
--- /dev/null
+++ b/examples/sandbox/basic.py
@@ -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),
+ )
+ )
diff --git a/examples/sandbox/data/f1040.pdf b/examples/sandbox/data/f1040.pdf
new file mode 100644
index 00000000..77556e80
Binary files /dev/null and b/examples/sandbox/data/f1040.pdf differ
diff --git a/examples/sandbox/data/sample_w2.pdf b/examples/sandbox/data/sample_w2.pdf
new file mode 100644
index 00000000..ecc05d99
Binary files /dev/null and b/examples/sandbox/data/sample_w2.pdf differ
diff --git a/examples/sandbox/docker/Dockerfile.mount b/examples/sandbox/docker/Dockerfile.mount
new file mode 100644
index 00000000..576d909b
--- /dev/null
+++ b/examples/sandbox/docker/Dockerfile.mount
@@ -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
diff --git a/examples/sandbox/docker/__init__.py b/examples/sandbox/docker/__init__.py
new file mode 100644
index 00000000..9fbdd0bf
--- /dev/null
+++ b/examples/sandbox/docker/__init__.py
@@ -0,0 +1 @@
+# Docker-specific sandbox examples.
diff --git a/examples/sandbox/docker/docker_runner.py b/examples/sandbox/docker/docker_runner.py
new file mode 100644
index 00000000..e64c891f
--- /dev/null
+++ b/examples/sandbox/docker/docker_runner.py
@@ -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))
diff --git a/examples/sandbox/docker/mounts/__init__.py b/examples/sandbox/docker/mounts/__init__.py
new file mode 100644
index 00000000..19a5fae3
--- /dev/null
+++ b/examples/sandbox/docker/mounts/__init__.py
@@ -0,0 +1 @@
+# Docker mount smoke-test examples.
diff --git a/examples/sandbox/docker/mounts/azure_mount_read_write.py b/examples/sandbox/docker/mounts/azure_mount_read_write.py
new file mode 100644
index 00000000..f29e5b9c
--- /dev/null
+++ b/examples/sandbox/docker/mounts/azure_mount_read_write.py
@@ -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())
diff --git a/examples/sandbox/docker/mounts/gcs_mount_read_write.py b/examples/sandbox/docker/mounts/gcs_mount_read_write.py
new file mode 100644
index 00000000..d9cbc81e
--- /dev/null
+++ b/examples/sandbox/docker/mounts/gcs_mount_read_write.py
@@ -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())
diff --git a/examples/sandbox/docker/mounts/mount_smoke.py b/examples/sandbox/docker/mounts/mount_smoke.py
new file mode 100644
index 00000000..54d0262e
--- /dev/null
+++ b/examples/sandbox/docker/mounts/mount_smoke.py
@@ -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)
diff --git a/examples/sandbox/docker/mounts/s3_files_mount_read_write.py b/examples/sandbox/docker/mounts/s3_files_mount_read_write.py
new file mode 100644
index 00000000..bfda1808
--- /dev/null
+++ b/examples/sandbox/docker/mounts/s3_files_mount_read_write.py
@@ -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())
diff --git a/examples/sandbox/docker/mounts/s3_mount_read_write.py b/examples/sandbox/docker/mounts/s3_mount_read_write.py
new file mode 100644
index 00000000..47b98089
--- /dev/null
+++ b/examples/sandbox/docker/mounts/s3_mount_read_write.py
@@ -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())
diff --git a/examples/sandbox/docs/__init__.py b/examples/sandbox/docs/__init__.py
new file mode 100644
index 00000000..e7f80899
--- /dev/null
+++ b/examples/sandbox/docs/__init__.py
@@ -0,0 +1 @@
+# Runnable coding-task assets for the sandbox agents docs.
diff --git a/examples/sandbox/docs/coding_task.py b/examples/sandbox/docs/coding_task.py
new file mode 100644
index 00000000..4e174bcd
--- /dev/null
+++ b/examples/sandbox/docs/coding_task.py
@@ -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))
diff --git a/examples/sandbox/docs/repo/README.md b/examples/sandbox/docs/repo/README.md
new file mode 100644
index 00000000..3fce4e4d
--- /dev/null
+++ b/examples/sandbox/docs/repo/README.md
@@ -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.
diff --git a/examples/sandbox/docs/repo/credit_note.sh b/examples/sandbox/docs/repo/credit_note.sh
new file mode 100644
index 00000000..228b3623
--- /dev/null
+++ b/examples/sandbox/docs/repo/credit_note.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+customer="$1"
+amount="$2"
+
+printf 'Credit note for %s: -$%s debit.\n' "$customer" "$amount"
diff --git a/examples/sandbox/docs/repo/task.md b/examples/sandbox/docs/repo/task.md
new file mode 100644
index 00000000..6b9491ff
--- /dev/null
+++ b/examples/sandbox/docs/repo/task.md
@@ -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.
diff --git a/examples/sandbox/docs/repo/tests/test_credit_note.sh b/examples/sandbox/docs/repo/tests/test_credit_note.sh
new file mode 100644
index 00000000..6e05edd0
--- /dev/null
+++ b/examples/sandbox/docs/repo/tests/test_credit_note.sh
@@ -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'
diff --git a/examples/sandbox/docs/skills/credit-note-fixer/SKILL.md b/examples/sandbox/docs/skills/credit-note-fixer/SKILL.md
new file mode 100644
index 00000000..f790ee29
--- /dev/null
+++ b/examples/sandbox/docs/skills/credit-note-fixer/SKILL.md
@@ -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.
diff --git a/examples/sandbox/extensions/README.md b/examples/sandbox/extensions/README.md
new file mode 100644
index 00000000..837d9dfa
--- /dev/null
+++ b/examples/sandbox/extensions/README.md
@@ -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:
+
+-
+-
+
+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 `
+- `--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:
+
+-
+-
+-
+
+If you want to configure credentials directly from the CLI:
+
+```bash
+uv run modal token set --token-id --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 ` -- mount an R2/S3 bucket via `CloudflareBucketMountStrategy`.
+- `--native-cloud-bucket-endpoint-url ` -- optional S3 endpoint URL.
+- `--api-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 `
+- `--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:
+
+-
+-
+
+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 ` 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.
diff --git a/examples/sandbox/extensions/__init__.py b/examples/sandbox/extensions/__init__.py
new file mode 100644
index 00000000..fb3e80a2
--- /dev/null
+++ b/examples/sandbox/extensions/__init__.py
@@ -0,0 +1 @@
+"""Manual validation examples for cloud sandbox extensions."""
diff --git a/examples/sandbox/extensions/blaxel_runner.py b/examples/sandbox/extensions/blaxel_runner.py
new file mode 100644
index 00000000..0a29e47e
--- /dev/null
+++ b/examples/sandbox/extensions/blaxel_runner.py
@@ -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 ")
+ 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,
+ )
+ )
diff --git a/examples/sandbox/extensions/cloudflare_runner.py b/examples/sandbox/extensions/cloudflare_runner.py
new file mode 100644
index 00000000..d30d2310
--- /dev/null
+++ b/examples/sandbox/extensions/cloudflare_runner.py
@@ -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,
+ )
+ )
diff --git a/examples/sandbox/extensions/daytona/__init__.py b/examples/sandbox/extensions/daytona/__init__.py
new file mode 100644
index 00000000..ca356089
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/__init__.py
@@ -0,0 +1 @@
+"""Daytona sandbox extension examples."""
diff --git a/examples/sandbox/extensions/daytona/daytona_runner.py b/examples/sandbox/extensions/daytona/daytona_runner.py
new file mode 100644
index 00000000..df59204f
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/daytona_runner.py
@@ -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,
+ )
+ )
diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/README.md b/examples/sandbox/extensions/daytona/usaspending_text2sql/README.md
new file mode 100644
index 00000000..69fa2de9
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/README.md
@@ -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.
diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/__init__.py b/examples/sandbox/extensions/daytona/usaspending_text2sql/__init__.py
new file mode 100644
index 00000000..90380e04
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/__init__.py
@@ -0,0 +1 @@
+"""USAspending text-to-SQL Daytona sandbox example."""
diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/agent.py b/examples/sandbox/extensions/daytona/usaspending_text2sql/agent.py
new file mode 100644
index 00000000..07d06557
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/agent.py
@@ -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())
diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/glossary.md b/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/glossary.md
new file mode 100644
index 00000000..2523552e
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/glossary.md
@@ -0,0 +1,1063 @@
+# USAspending Glossary
+
+Official definitions from [USAspending.gov](https://www.usaspending.gov).
+Retrieved automatically by setup_db.py (149 terms).
+
+## Account Balance (File A)
+
+After the end of every month (or in some select cases every quarter), agencies report the balances that are in their financial systems to USAspending in what is labeled “File A.” Because this data is based on Treasury Accounts (TAS), it is often referred to as “Account Data” or “Account Spending.”
+
+**Official definition:** Account Balance data is reported in File A, one of the three files that each agency publishes to USAspending.gov in its financial data submission each month (or quarter for some agencies). The file stems from the agency’s audited financial system and is validated against the Governmentwide Treasury Account Symbol Adjusted Trial Balance System (GTAS). File A includes data on total budgetary resources and total spending, including obligations and outlays, by Treasury Account Symbol (TAS). It also provides the relevant budget function associated with spending.
+When you see a reference to Account Balance (File A) on the site, the reference is to the dataset comprising all agency Files A submissions and not one specific agency file.
+
+## Account Breakdown by Award (File C)
+
+Account Breakdown by Award (File C) is one of the three files that each agency publishes to USAspending.gov in its financial data submission each month (or quarter for some agencies). The file stems from the agency’s audited financial system and includes data on award spending only (i.e., excludes non-award spending). Account Breakdown by Award (File C) provides details such as the timing, type, and recipient for each award.
+When you see a reference to Account Breakdown by Award (File C) on the site, the reference is to the dataset comprising all agency Files C and not one specific agency file.
+
+## Account Breakdown by Program Activity & Object Class (File B)
+
+Account Breakdown by Program Activity & Object Class (File B) is one of the three files that each agency publishes to USAspending.gov in its financial data submission each month (or quarter for some agencies). The file stems from the agency’s audited financial system and includes data on total budgetary spending, including obligations and outlays, by Treasury Account Symbol. Like Account Balances (File A), this file provides the relevant budget function associated with spending. In contrast with Account Balances (File A) this file also includes the relevant object class and program activity.
+When you see a reference to Account Breakdown by Program Activity & Object Class (File B) on the site, the reference is to the dataset comprising all agency Files B and not one specific agency file.
+
+## Acquisition of Assets
+
+This major object class includes an agency’s procurement of assets, including those that have lost value (depreciated). Some examples of assets, according to this definition, include equipment, land, physical structures, investments, and loans.
+
+**Official definition:** This major object class covers object classes 31.0 through 33.0. Include
+capitalized (depreciated) assets and non-capitalized assets. This includes:
+31.0 Equipment
+32.0 Land and structures
+33.0 Investments and loans
+
+Each specific object class is defined in OMB Circular A-11 Section 83.6.
+
+## Action Date
+
+The date the action being reported (for prime award transactions or sub-awards) was issued or signed by the Government, or a binding agreement was reached. Because award obligations are tied to action dates, any search for spending data on USAspending will search by this data element rather than by Period of Performance dates.
+
+## Action Type
+
+Provides information on the type of change made to an award. For example, the change may be the result of a continuation, revision, and/or adjustment to completed project.
+
+**Official definition:** Description (and corresponding code) that provides information on any changes made to the Federal prime award. There are typically multiple actions for each award.
+
+(Note: This definition encompasses current data elements ‘Type of Action’ for financial assistance and ‘Reason for Modification’ for procurement)
+
+## Agency
+
+On this website, we use the term agency to mean any federal department, commission, or other U.S. government entity. Agencies can have multiple sub-agencies. For example, the National Park Service is a sub-agency of the U.S. Department of the Interior.
+
+## Agency Identifier
+
+Identifies the agency responsible for a Treasury account. This is a 3-digit number that is a part of a Treasury Account Symbol (TAS).
+
+**Official definition:** The agency code identifies the department or agency that is responsible for the account.
+
+## Allocation Transfer Agency (ATA) Identifier
+
+Identifies an agency that receives funds through an allocation (non-expenditure) transfer. This is a 3-digit number that is a part of a Treasury Account Symbol (TAS).
+
+**Official definition:** The allocation agency identifies the department or agency that is receiving funds through an allocation (non-expenditure) transfer.
+
+## Appropriation
+
+The process by which Congress designates and approves spending for a specific purpose (e.g., a project or program). Most government spending is determined through appropriation bills each year. These bills must be passed by Congress and signed by the President.
+
+When an appropriation is not passed by Congress before the beginning of the fiscal year, a “continuing resolution” (often referred to as a “CR”) may be enacted to avoid a government shutdown. A CR is a law that provides stopgap funding for agencies until their regular appropriations are passed.
+
+## Appropriation Account
+
+When Congress passes a law, it often gives an agency authority to carry out a project. When this happens, Congress may set aside money for the project. An appropriation account tracks the money, much like a bank account. The appropriation account number (like a bank account number) is called a Treasury Account Symbol (TAS).
+
+**Official definition:** The basic unit of an appropriation generally reflecting each unnumbered paragraph in an appropriation act. An appropriation account typically encompasses a number of activities or projects and may be subject to restrictions or conditions applicable to only the account, the appropriation act, titles within an appropriation act, other appropriation acts, or the Government as a whole.
+
+An appropriations account is represented by a TAFS created by Treasury in consultation with OMB.
+
+(defined in OMB Circular A-11)
+
+## Assistance Listings (CFDA Program)
+
+Assistance Listings, previously known as "CFDA programs", provide a full listing of federal programs that are available to organizations, government agencies (state, local, tribal), U.S. territories, and individuals who are authorized to do business with the government. An Assistance Listing program can be a project, service, or activity. Each program has a unique, 5-digit number in the form of XX.XXX. The first two digits represent the funding agency. The last three digits represent the program.
+
+Examples of Assistance Listings include:
+
+* Social Security Retirement Insurance (96.002)
+* Medicare Supplementary Medical Insurance (93.774)
+* Supplemental Nutrition Assistance Program (10.551)
+* Highway Planning and Construction (20.205)
+* National School Lunch Program (10.555)
+
+**Official definition:** The number assigned to an Assistance Listing in the Catalog of Federal Domestic Assistance (CFDA) and SAM.gov.
+
+The title of the Assistance Listing under which the Federal award was funded in the Catalog of Federal Domestic Assistance (CFDA) and SAM.gov.
+
+## Availability Type Code
+
+Within a Treasury Account Symbol (TAS), this one-letter code Identifies the availability (or time period) for obligations to be made on the appropriation account. A TAS will have an “X” if there is an unlimited or indefinite period to incur new obligations.
+
+**Official definition:** In appropriations accounts, the availability type code identifies an unlimited period to incur new obligations; this is denoted by the letter X.
+
+## Award
+
+Money the federal government has promised to pay a recipient. Funding may be awarded to a company, organization, government entity (i.e., state, local, tribal, federal, or foreign), or individual. It may be obligated (promised) in the form of a contract, grant, loan, insurance, direct payment, etc.
+
+## Award Amount
+
+The amount that the federal government has promised to pay (obligated) a recipient, because it has signed a contract, awarded a grant, etc.
+
+**Official definition:** The cumulative amount obligated by the Federal Government for an award, which is calculated by USAspending.gov.
+
+For procurement and financial assistance awards except loans, this is the sum of Federal Action Obligations.
+
+For loans or loan guarantees, this is the Original Subsidy Cost.
+
+## Award ID
+
+A unique identification number for each individual award.
+
+**Official definition:** The unique identifier of the specific award being reported, i.e. Federal Award Identification Number (FAIN) for financial assistance and Procurement Instrument Identifier (PIID) for procurement.
+
+## Award Type
+
+The federal government can distribute funding in several forms, including contracts, grants, loans, insurance, and direct payments. Award Type is a classification that provides more information about the structure of the award. Examples include:
+
+- Purchase Order (a type of contract)
+- Definitive Contract (a type of contract)
+- Block Grant (a type of grant)
+- Direct Loan (a type of loan)
+
+**Official definition:** Description (and corresponding code) that provides information to distinguish type of contract, grant, or loan and providers the user with more granularity into the method of delivery of the outcomes.
+
+## Awarding Agency
+
+The Awarding Agency is the agency that issues and administers the award. This agency usually pays for the funding out of its own budget. In some cases, the money is financed by another agency, called the Funding Agency.
+
+**Official definition:** The name and code associated with a department or establishment of the Government as used in the Treasury Account Fund Symbol (TAFS).
+
+## Awarding Office
+
+The office within an agency that issues and administers the award.
+
+**Official definition:** Name and identifier of the level n organization that awarded, executed or is otherwise responsible for the transaction.
+
+## Awarding Sub-Agency
+
+The Awarding Sub Agency is the sub agency that issues and administers the award. For example, the Internal Revenue Service (IRS) is a sub agency of the Department of the Treasury.
+
+**Official definition:** Name and identifier of the level 2 organization that awarded, executed or is otherwise responsible for the transaction.
+
+## Awards Data (File D)
+
+Awards Data is ingested up to daily from government-wide systems where agencies submit financial assistance and procurement data. Because it comprises two separate datasets, it is sometimes referred to as Procurement Data (File D1) and Assistance Data (File D2). Awards Data is separate from the financial data submissions that agencies publish to USAspending.gov each month or quarter (the submissions that include Files A, B, and C). Data from File D1/D2 supplements award data found in Account Breakdown by Award (File C) to provide a full picture of award spending.
+When you see a reference to File D on the site, it refers to the up-to-date set of all agencies’ procurement (File D1) and assistance (File D2) datasets and not one specific agency’s files.
+
+## Balance Brought Forward
+
+Funds that were not spent (obligated or outlaid) in previous years and are authorized to be spent in the current year.
+
+**Official definition:** The definition for this element appears in Appendix F of OMB Circular A-11 issued June 2015; a brief summary from A-11 appears below. For unexpired accounts: Amount of unobligated balance of appropriations or other budgetary resources carried forward from the preceding year and available for obligation without new action by Congress. For expired accounts: Amount of expired unobligated balances available for upward adjustments of obligations.
+
+## Base Transaction Action Date
+
+The action date of the original Prime Award Transaction of a Prime Award Summary. Note that this date may be different from the Period of Performance Start Date. Because award obligations are tied to action dates, any search for spending data on USAspending will search by this data element rather than by Period of Performance dates.
+
+## Base Transaction Description
+
+A brief description of the purpose of the award.
+
+**Official definition:** For procurement awards: Per the FPDS data dictionary, a brief, summary level, plain English, description of the contract, award, or modification. Additional information: the description field may also include abbreviations, acronyms, or other information that is not plain English such as that required by OMB policies (CARES Act, etc).
+
+For financial assistance awards: A plain language description of the Federal award purpose; activities to be performed; deliverables and expected outcomes; intended beneficiary(ies); and subrecipient activities if known/specified at the time of award.
+
+## Basic Ordering Agreement (BOA)
+
+A Basic Ordering Agreement (BOA) is a type of Indefinite Delivery Vehicle (IDV). It is not a contract; it is a written understanding between government and contractor. It details the supplies or services offered. It also details pricing and delivery for future orders.
+
+BOA's can speed up contracting when requirements are uncertain. For instance, when specifications, quantities, and prices are not yet known.
+
+These agreements can also help the government achieve economies of scale for part orders. For the contractor, they can lessen lead-time, enable a larger inventory investment, and lessen old inventory.
+
+## Beginning Period of Availability
+
+Identifies the first year that an appropriation account may incur new obligations. This is for annual and multi-year funds only. This is a 4-digit number representing the year (e.g., 2017). It is a part of a Treasury Account Symbol (TAS).
+
+**Official definition:** In annual and multi-year funds, the beginning period of availability identifies the first year of availability under law that an appropriation account may incur new obligations.
+
+## Blanket Purchase Agreement (BPA)
+
+A Blanket Purchase Agreement (BPA) is a method federal agencies use to make repeat purchases of supplies or services. A type of Indefinite Delivery Vehicle (IDV), a BPA operates by setting up a "charge account" with trusted vendors. Both agencies and vendors often prefer BPAs because they help speed up the process of repeated purchases. Once a BPA is set up, repeat purchases are easy for both sides.
+
+A BPA is an agreement with an individual agency, meaning only a handful of offices can place orders on a BPA. A BPA can be awarded to a set of vendors, who will then be able to bid on upcoming orders. A BPA can be set up with or without General Services Administration (GSA) schedules. Without GSA schedules, orders are capped at the Simplified Acquisition Threshold (SAT) of $100,000.
+
+Examples of BPAs:
+
+- Agency A establishes a BPA with a computer manufacturer for repeat laptop purchases
+- Agency B establishes a BPA with a graphic design agency for design of brochures and event signage
+
+## Block Grant
+
+Block grants are awarded by the federal government to state and local governments for broadly defined purposes — for example, social services or community development.
+
+**Official definition:** Block grants are given primarily to general purpose governmental units in accordance with a statutory formula. Such grants can be used for a variety of activities within a broad functional area. Examples of federal block grant programs are the Omnibus Crime Control and Safe Streets Act of 1968, the Housing and Community Development Act of 1974, and the grants to states for social services under title XX of the Social Security Act.
+
+## Budget Authority
+
+A federal agency is only allowed to spend money if Congress provides the authority by law for that spending. That permission to spend is called “budget authority.”
+
+Budget authority can be granted through an appropriation law, which specifies a purpose, usually a maximum amount of money, and a set time period. Budget authority can also be granted for spending unused funds from a previous year, or to spend money that the agency takes in (e.g., the National Park Service is authorized to spend fees collected for park admission regardless of the amount).
+
+**Official definition:** The total amount of all obligation budget authority including unobligated balances carried forward, adjustments to unobligated balances carried forward, appropriated amounts, and other budgetary resources, as of the reported date.
+
+## Budget Authority Appropriated
+
+A provision of law (not necessarily in an appropriations act) authorizing an account to incur obligations and to make outlays for a given purpose. Usually, but not always, an appropriation provides budget authority.
+
+(defined in OMB Circular A-11)
+
+## Budget Function
+
+The federal budget is divided into approximately 20 categories, known as budget functions. These categories organize federal spending into topics based on the major purpose the spending serves (e.g., National Defense, Transportation, Health).
+
+These are further broken down into budget sub functions.
+
+## Budget Sub-Function
+
+The federal budget is divided into functions and sub functions. These categories organize federal spending into topics based on the major purpose the spending serves. There are about 20 major functions (e.g., National Defense, Transportation, Health). Most of these functions are further divided into sub functions.
+
+For example, the budget function for Health is divided into sub functions for Health care services, Health research and training, and Consumer and occupational health and safety.
+
+## Budgetary Resources
+
+Budgetary resources mean amounts available to incur obligations in a given year. Budgetary resources consist of new budget authority (from appropriations, borrowing authority, contract authority, or offsetting collections) and unobligated balances of budget authority provided in previous years. On this website, budgetary resources do not include financing accounts, which are a type of treasury account used to finance federal loans and are not considered spending per Office of Management and Budget (OMB) policy. For the purposes of USASpending.gov, “funding” represents “budgetary resources”.
+
+Budgetary resources include financial transfers between Government accounts. Financial transfers are financial interchanges between Federal Government accounts that are not an exchange for goods and services. For example, an expenditure transfer that shifts budgetary resources between a General Fund account, (e.g., Payment to Highway Trust Fund) and a trust fund (e.g., Highway Trust Fund) is considered a financial transfer. For financial transfers, budgetary resources are shown in both accounts.
+
+## Clinger-Cohen Act
+
+The Clinger-Cohen Act (CCA) of 1996 is a federal law designed to improve the way the federal government acquires, uses, and disposes of IT. It strives to make IT purchases more strategic.
+
+**Official definition:** A code indicating the funding office has certified that the information technology purchase meets the planning requirements in 40 USC 11312 and 40 USC 11313.
+
+## Construction Wage Rate Requirements
+
+Indicates whether the transaction is subject to the Construction Wage Rate Requirements. The clause is 52.222-6 "Construction Wage Rate Requirements" -that goes with Wage Rate Requirements (Construction) (formerly Davis-Bacon Act).
+
+## Contract
+
+An agreement between the federal government and a prime recipient to provide goods and services for a fee.
+
+**Official definition:** Contract means a mutually binding legal relationship obligating the seller to furnish the supplies or services (including construction) and the buyer to pay for them. It includes all types of commitments that obligate the government to an expenditure of appropriated funds and that, except as otherwise authorized, are in writing. In addition to bilateral instruments, contracts include (but are not limited to) awards and notices of awards; job orders or task letters issued under basic ordering agreements; letter contracts; orders, such as purchase orders, under which the contract becomes effective by written acceptance or performance; and bilateral contract modifications. Contracts do not include grants and cooperative agreements covered by 31 U.S.C. 6301, et seq.
+
+## Contract Pricing Type
+
+Payment model for a contract. Each has a different way of accounting for costs, fees, and profits. Contract pricing types include:
+
+- Fixed Price Redetermination
+- Fixed Price Level of Effort
+- Firm Fixed Price
+- Fixed Price with Economic Price Adjustment
+- Fixed Price Incentive
+- Fixed Price Award Fee
+- Cost Plus Award Fee
+- Cost No Fee
+- Cost Sharing
+- Cost Plus
+- Fixed Fee
+- Cost Plus Incentive Fee
+- Time and Materials
+- Labor Hours
+
+**Official definition:** The type of contract as defined in FAR Part 16 that applies to this procurement.
+
+## Contractor
+
+A business, organization, or agency that receives funding and/or performs work on a contract. A contractor may be a corporation, small business, university, non-profit, sole proprietor, or other entity. When a company has a contract with the U.S. government, they may hire another company to perform part of the work. When this happens, the company who received the award is called the prime contractor. The company hired by the prime is called the sub-contractor.
+
+## Contractual Services and Supplies
+
+This major object class includes services or supplies purchased to support the fulfillment of government activities during a specified contract period. Some examples include transportation of government personnel and supplies, rent and other utilities, rental payments made to GSA, printing and reproduction costs, and operations/maintenance costs for federal facilities.
+
+These items are not equivalent to the Federal Acquisition Regulation (FAR) federal contract award spending and will not match total contract award spending on USAspending.gov.
+
+**Official definition:** This major object class covers purchases of contractual services and supplies in object classes 21.0 through 26.0, including:
+21.0 Travel and transportation of persons
+22.0 Transportation of things, Rent, Communications, and Utilities
+23 Rent, Communications, and Utilities
+23.1 Rental payments to GSA
+23.2 Rental payments to others
+23.3 Communications, utilities, and miscellaneous charges
+24.0 Printing and reproduction
+25 Other contractual services
+25.1 Advisory and assistance services
+25.2 Other services from non-Federal sources
+25.3 Other goods and services from Federal sources
+25.4 Operation and maintenance of facilities
+25.5 Research and development contracts
+25.6 Medical care
+25.7 Operation and maintenance of equipment
+25.8 Subsistence and support of persons
+26.0 Supplies and materials
+
+Each specific object class is defined in OMB Circular A-11 Section 83.6.
+
+## Cooperative Agreement
+
+Grant awarded to provide assistance. It is characterized by extended involvement between recipient and agency. It requires substantial oversight by the agency, and includes reporting requirements.
+
+## Current Award Amount
+
+The amount of money that the government has promised (obligated) to pay a recipient for a contract. This means the base amount and any exercised options.
+
+**Official definition:** For procurement, the total amount obligated to date on a contract, including the base and exercised options.
+
+## Definitive Contract
+
+A Definitive Contract is a mutually binding legal relationship obligating the seller to provide the supplies or services (including construction) and the buyer to pay for them. It includes all types of commitments that obligate the Government to an expenditure of appropriated funds and that, except as otherwise authorized, are in writing. In addition to bilateral instruments, contracts include (but are not limited to) awards and notices of awards; job orders, or task letters, issued under basic ordering agreements; letter contracts; orders, such as purchase orders, under which the contract becomes effective by written acceptance or performance; and bilateral contract modifications.
+
+## Delivery Order Contract
+
+An Indefinite Quantity Contract for supplies (not services) is sometimes referred to as a Delivery Order Contract. With this type of contract, the government promises to buy supplies over a period of time from a vendor. Instead of an exact amount, it sets a quantity range with a minimum and maximum.
+
+## Deobligation
+
+The cancellation or downward adjustment of previously obligated funds. Agencies deobligate funds to decrease the amount available under an award. Deobligated funds may be reobligated within the period of availability of the appropriation.
+
+## Direct Loan
+
+Direct loan means a disbursement of funds by the Government to a non-Federal borrower under a contract that requires the repayment of such funds with or without interest. The term also includes certain equivalent transactions that extend credit.
+
+## Direct Payment
+
+A cash payment made by the federal government to an individual, a private firm, or another private institution.
+
+## Direct Payment for Specified Use
+
+Financial assistance provided by the federal government directly to individuals, private firms, and other private institutions for a particular activity. To receive this assistance, the recipient must perform certain agreed-upon activities and meet certain milestones. Direct payments don’t include solicited contracts for the procurement of goods and services for the government.
+
+**Official definition:** Includes financial assistance from the Federal government provided directly to individuals, private firms, and other private institutions to encourage or subsidize a particular activity by conditioning the receipt of the assistance on a particular performance by the recipient.
+
+## Direct Payment with Unrestricted Use
+
+Financial assistance provided by the federal government directly to beneficiaries who meet certain federal eligibility requirements. This type of assistance doesn’t place any restrictions on how the recipient spends the money. Some examples of direct payments include retirement, pension, and compensatory programs.
+
+## Disaster Emergency Fund Code (DEFC)
+
+Disaster Emergency Fund Code (DEFC) is used to track the spending of funding for disasters and emergencies such as COVID-19. Each code links to one or more legislative bills that authorized the funding.
+
+**Official definition:** The Office of Management and Budget (OMB), working with the Department of Treasury’s Fiscal Service, has identified a Government-wide Treasury Account Symbol Adjusted Trial Balance System (GTAS) attribute called ‘Disaster Emergency Fund Code (DEFC)’ to track appropriations classified as disaster or emergency. This code applies to the budgetary resources, obligations incurred, unobligated and obligated balances, and outlays that result from these appropriations.
+
+
+As established in Memorandum M-18-08, the domain value set for DEFC is a single letter from ‘A’ to ‘Z’. The default domain value for all funding without disaster or emergency designation is ‘Q’. OMB assigns a new DEFC domain value from the set to each enacted appropriation with disaster or emergency funding. The corresponding domain title for each DEFC domain value identifies the associated public law number(s) and whether the funding is disaster or emergency.
+
+
+Memorandum M-20-21 amended the above to allow agencies to use DEFC to meet reporting requirements for COVID-19 supplemental funding, which required tracking of funds not designated as emergency.
+
+
+Agencies use the following DEFC domain values and titles for COVID-19 supplemental funding:
+
+- **DEFC ‘L’** Public Law 116-123, designated as emergency
+- **DEFC ‘M’** Public Law 116-127, designated as emergency
+- **DEFC ‘N’** Public Law 116-136, designated as emergency
+- **DEFC ‘O’** Public Law 116-136, Public Law 116-139, and Public Law 116-260, not designated as emergency
+- **DEFC ‘P’** Public Law 116-139, designated as emergency
+- **DEFC ‘U’** Public Law 116-260, designated as emergency
+- **DEFC ‘V’** Public Law 117-2, American Rescue Plan Act of 2021, not designated as emergency
+
+
+Note that the National Interest Action (NIA) code is also used to track COVID-19 spending. However, it only applies to procurement actions (i.e., contracts) and is not necessarily tied to COVID-19 supplemental appropriations. Thus, awards with the COVID-19 NIA value may not have a COVID-19 DEFC value, and vice versa.
+
+## DOD Claimant Program Code
+
+Department of Defense (DOD) code that designates a grouping of supplies, construction, or other services. Each code has letters and numbers.
+
+**Official definition:** A claimant program number designates a grouping of supplies, construction, or other services.
+
+## DUNS
+
+DUNS stands for Data Universal Numbering System. It is a unique 9-digit identification number assigned to a company or organization by Dun & Bradstreet, Inc. A DUNS is required to register in the System for Award Management (SAM). An organization must be registered in SAM (and obtain a DUNS) to do business with the federal government. There is a separate DUNS number for each business location in the Dun & Bradstreet database. The DUNS number is random, and specific digits have no significance.
+
+**Official definition:** The unique identification number for an awardee or recipient. Currently the identifier is the 9-digit number assigned by Dun & Bradstreet referred to as the DUNS® number.
+
+## Ending Period of Availability
+
+Identifies the last year that an appropriation account may incur new obligations. This is for annual and multi-year funds only. This is a 4-digit number representing the year (e.g., 2018). It is a part of a Treasury Account Symbol (TAS).
+
+**Official definition:** In annual and multi-year funds, the end period of availability identifies the last year of funds availability under law that an appropriation account may incur new obligations.
+
+## Extent Competed
+
+A code that represents the competitive nature of the contract. Values include:
+
+- A = Full and open competition (competitive proposal, no sources excluded)
+- B = Not available for competition
+- C = Not competed
+- D = Full and open competition after exclusion of sources
+- E = Follow-on to competed action (a follow-on to an existing competed contract)
+- F = Competed under Simplified Acquisition Threshold (SAP)
+- G = Not competed under Simplified Acquisition Threshold (SAP)
+
+**Official definition:** A code that represents the competitive nature of the contract.
+[Read the Federal Procurement Data System definition](https://www.fpds.gov/help/Extent_Competed.htm).
+
+## Face Value of Loan
+
+Face value of a loan is the total amount of the loan, and the amount that agencies have directly issued (for direct loans) or facilitated by compensating the lender if the borrower defaults (for loan guarantees).
+
+Since loans are expected to be paid back, in budgetary terms, the face value of a loan is not considered spending and is not included in any obligation or outlay figure. However, because not all loans are repaid, they do have costs to the government. The government’s calculation of these costs is called subsidy cost.
+
+**Official definition:** The face value of the direct loan or loan guarantee.
+
+## FAIN
+
+An identification code assigned to a specific financial assistance award by an agency for tracking purposes. The FAIN is tied to that award (and all future modifications to that award) throughout the award's life. Within an agency, FAINs are unique; a new award must be issued a new FAIN. FAIN stands for Federal Award Identification Number, though the digits may be both letters and numbers.
+
+**Official definition:** The Federal Award Identification Number (FAIN) is the unique ID within the Federal agency for each financial assistance award.
+
+## Federal Account
+
+Federal Accounts refer to the set of Treasury spending accounts that are grouped under a given "Federal Account Symbol." On this website we group them by their agency identifier (3-digit code) and Main Account code (4-digit code).
+
+## Federal Action Obligation
+
+Amount of Federal Government’s obligation, de-obligation, or liability, in dollars, for an award transaction.
+
+## Federal Supply Schedule (FSS)
+
+The Federal Supply Schedule (FSS) is a listing of contractors that have been awarded a contract by GSA that can be used by all federal agencies. This is also known as a Multiple Award Schedule (MAS).
+
+## Financial Assistance
+
+A federal program, service, or activity that directly aids organizations, individuals, or state/local/tribal governments. Sectors include education, health, public safety and public welfare - to name a few. Financial assistance is distributed in many forms, including grants, loans, direct payments, or insurance.
+
+## Fiscal Year (FY)
+
+The fiscal year is an accounting period that spans 12 months. For the federal government, it runs from October 1 to September 30. For example, Fiscal Year 2017 (FY 2017) starts October 1, 2016 and ends September 30, 2017.
+A fiscal year may be broken down into quarters. For the federal government, these quarters are:
+
+- Q1: October - December
+- Q2: January - March
+- Q3: April - June
+- Q4: July - September
+
+## Formula Grant
+
+An allocation made to states (or their subdivisions, which include county and local governments, among other entities) according to law. These grants are awarded for continuing activities that aren’t confined to a specific project — for example, Medicaid.
+
+**Official definition:** Allocations made to states (or their subdivisions) according to law or administrative regulation. These grants are awarded for continuing activities that aren’t confined to a specific project.
+
+## Funding Agency
+
+A Funding Agency pays for the majority of funds for an award out of its budget. Typically, the Funding Agency is the same as the Awarding Agency. In some cases, one agency will administer an award (Awarding Agency) and another agency will pay for it (Funding Agency).
+
+**Official definition:** Name and 3-digit CGAC agency code of the department or establishment of the Government that provided the preponderance of the funds for an award and/or individual transactions related to an award.
+
+## Funding Obligated
+
+The amount of money that an agency has promised to pay, usually because the agency has signed a contract, awarded a grant, or placed an order for goods or services.
+
+In the "Financial Systems Details" tab on an award summary page, this amount refers to the funding obligated in an agency's financial system.
+
+**Official definition:** The definition for this element appears in Section 20 of OMB Circular A-11 issued June 2015; a brief summary from A-11 appears below.
+
+Obligation means a binding agreement that will result in outlays, immediately or in the future. Budgetary resources must be available before obligations can be incurred legally.
+
+## Funding Office
+
+The office within an agency that pays the majority of funds for an award out of its budget.
+
+**Official definition:** Name and identifier of the level n organization that provided the preponderance of the funds obligated by this transaction.
+
+## Funding Opportunity Goals Text
+
+A brief summary of the intended outcomes associated with the notice of funding opportunity.
+
+## Funding Opportunity Number
+
+An alphanumeric identifier that a Federal agency assigns to its funding opportunity announcement as part of the Notice of Funding Opportunity posted on the OMB-designated government-wide web site (currently grants.gov) for finding and applying for Federal financial assistance.
+
+## Funding Sub-Agency
+
+A component of a larger department or agency that pays for the majority of funds for an award out of its budget. Also known as a sub-tier agency. For example, Bureau of Indian Affairs is a sub-agency of Department of Interior.
+
+**Official definition:** Name and identifier of the level 2 organization that provided the preponderance of the funds obligated by this transaction.
+
+## Government wide Acquisition Contract (GWAC)
+
+Government-Wide Acquisition Contract (GWAC) is a multi-agency contract. It offers Information Technology (IT) services to agencies across the government. It is an Indefinite Delivery Vehicle (IDV) for certain types of IT work:
+
+- Systems design
+- Software engineering
+- Information assurance
+- Enterprise architecture
+
+Vendors compete for the initial contracts. Once selected, they are eligible to compete further for agency-specific tasks.
+
+## Governmentwide Spending Data Model (GSDM)
+
+The Governmentwide Spending Data Model (GSDM), formerly called the DATA Act Information Model Schema (DAIMS), is the authoritative source for the data elements that establish government-wide data standards for spending data and their subsequent publication for transparency.
+
+**Official definition:** The Governmentwide Spending Data Model (GSDM), formerly called the DATA Act Information Model Schema (DAIMS), was created as a result of the Digital Accountability and Transparency Act of 2014 (DATA Act). The GSDM is the authoritative source for the terms, definitions, formats and structures for hundreds of distinct data elements that establish government-wide data standards for spending data and their subsequent publication for transparency.
+
+The Office of Management and Budget (OMB) and Department of the Treasury (Treasury) collected public input and feedback from federal agencies and implemented an agile development methodology to create the DAIMS. The finalized DAIMS first published in April 2016. Since then, Treasury has periodically published updates to reflect the inclusion of legislation and policies that go beyond the DATA Act.
+
+In November 2023, DAIMS was rebranded as the GSDM to reflect the inclusion of new legislation and policies. The GSDM includes artifacts that provide technical guidance for federal agencies about what data to report to Treasury including the authoritative sources of the data elements and the submission format. The GSDM documents also provide data consumers with information and context to better understand the inherent complexity of the data.
+
+## Grant
+
+An award of financial assistance from a federal agency to a recipient to carry out a public project or service authorized by a United States law. Unlike loans, grants do not need to be repaid. Most grants are awarded to state and local governments. On this site, you’ll see reference to several types of grants, including block grants, formula grants, project grants, and cooperative agreements.
+
+**Official definition:** A federal financial assistance award making payment in cash or in kind for a specified purpose. The federal government is not expected to have substantial involvement with the state or local government or other recipient while the contemplated activity is being performed. The term “grant” is used broadly and may include a grant to nongovernmental recipients as well as one to a state or local government, while the term “grant-in-aid” is commonly used to refer only to a grant to a state or local government. (For a more detailed description, see the Federal Grant and Cooperative Agreement Act of 1977, 31 U.S.C. §§ 6301–6308.) The two major forms of federal grants-in-aid are block and categorical.
+
+## Grants and Fixed Charges
+
+This major object class includes grants, subsidies, and contributions to foreign countries; insurance claims; indemnities (for example, payments to veterans for death or disability, or to compensate for loss of property); interest and dividends; and refunds.
+
+**Official definition:** This major object class covers object classes 41.0 through 44.0. This includes:
+41.0 Grants, subsidies, and
+contributions
+42.0 Insurance claims and
+indemnities
+43.0 Interest and dividends
+44.0 Refunds
+
+Each specific object class is defined in OMB Circular A-11 Section 83.6.
+
+## Guaranteed / Insured Loans
+
+Loan guarantee means any guarantee, insurance, or other pledge with respect to the payment of all or a part of the principal or interest on any debt obligation of a non-Federal borrower to a non-Federal lender. The term does not include the insurance of deposits, shares, or other withdrawable accounts in financial institutions.
+
+## Highly Compensated Officer Name
+
+First Name: The first name of an individual identified as one of the five most highly compensated “Executives.” “Executive” means officers, managing partners, or any other employees in management positions.
+
+Middle Initial: The middle initial of an individual identified as one of the five most highly compensated “Executives.” “Executive” means officers, managing partners, or any other employees in management positions.
+
+Last Name: The last name of an individual identified as one of the five most highly compensated “Executives.” “Executive” means officers, managing partners, or any other employees in management positions.
+
+## Highly Compensated Officer Total Compensation
+
+The cash and noncash dollar value earned by the one of the five most highly compensated “Executives” during the awardee's preceding fiscal year and includes the following (for more information see 17 C.F.R. § 229.402(c)(2)): salary and bonuses, awards of stock, stock options, and stock appreciation rights, earnings for services under non-equity incentive plans, change in pension value, above-market earnings on deferred compensation which is not tax qualified, and other compensation.
+
+## Indefinite Delivery / Definite Quantity Contract
+
+An indefinite delivery contract (IDC) facilitates the delivery of supply and service orders during a set timeframe. This type of contract is awarded to one or more vendors.
+
+Definite Quantity Contracts, which are a type of IDC, provide for delivery of a definite quantity of supplies or services for a fixed period, with deliveries to be scheduled at designated locations upon order.
+
+## Indefinite Delivery / Indefinite Quantity (IDIQ) Contract
+
+An Indefinite Quantity Contract is a type of Indefinite Delivery Contract (IDC). Sometimes the government contracts to buy supplies or services from a vendor over a period of time. For instances that government does not know the exact quantity it will need, an Indefinite Quantity Contract sets a quantity range with a min and max. It does not specify an exact number. For services, this is often called a Task Order Contract. For supplies, this is often called a Delivery Order Contract.
+
+## Indefinite Delivery / Requirements Contract
+
+Requirements contracts are for the fulfillment of all purchase requirements of supplies or services for designated government activities during a specified contract period, with deliveries to be scheduled by placing orders with the contractor.
+
+## Indefinite Delivery Contract (IDC)
+
+Indefinite Delivery Contract (IDC) facilitates the delivery of supply and service orders during a set timeframe. This type of contract is awarded to one or more vendors.
+
+Types of IDC's Include:
+
+- Indefinite Delivery / Definite Quantity Contract
+- Indefinite Delivery / Requirements Contract
+- Indefinite Delivery / Indefinite Quantity (IDIQ) Contract
+
+## Indefinite Delivery Vehicle (IDV)
+
+Vehicle to facilitate the delivery of supply and service orders. IDV Types include:
+
+- Blanket Purchase Agreement (BPA)
+- Basic Ordering Agreement (BOA)
+- Government-Wide Acquisition Contract (GWAC)
+- Multi-Agency Contract
+- Indefinite Delivery Contract (IDC)
+- Federal Supply Schedule (FSS)
+
+## Indirect Cost Federal Share Amount
+
+The total amount of any single Federal award action that is allocated, per the award recipient’s approved award budget, to indirect costs.
+
+## Insurance
+
+Financial assistance provided to assure reimbursement for losses sustained under specified conditions. Coverage may be provided directly by the Federal government or through private carriers and may or may not involve the payment of premiums. See Catalog for Federal Domestic Assistance (CFDA).
+
+## Labor Standards
+
+Indicates whether the transaction is subject to the Labor Standards. The clause for Labor Standards is 52.222-41 "Labor Standards" - that goes with the Service Contract Labor Standards (formerly Service Contract Act).
+
+## Latest Transaction Action Date
+
+The action date of the most recent Prime Award Transaction of a Prime Award Summary. Note that this date may be different from the Period of Performance End Date (Current or Potential). Because award obligations are tied to action dates, any search for spending data on USAspending will search by this data element rather than by Period of Performance dates.
+
+## Legal Entity Country Name and Code
+
+The Name and Code for the country in which the awardee or recipient is located, using the ISO 3166-1 Alpha-3 GENC Profile, and not the codes listed for those territories and possessions of the United States already identified as “states.”
+
+## Loan
+
+A federal award from the government that the borrower will eventually have to pay back. Direct loans are those made for a specific time period with a reasonable expectation of repayment; they may or may not require interest payments. Guaranteed loans require the federal government to pay the bank and take over the loan if the borrower defaults.
+
+## Loan Subsidy Cost
+
+When the government makes a direct loan or guarantees a loan, it expects the loan to be repaid. However, for any given loan program (e.g., student loans, small business loan guarantees) some individual loans are not repaid. Subsidy cost is the government’s way to estimate a loan’s likely cost to the government based on the size of the loan (i.e., its Face value), interest rate, the modeled risk of default in full or in part, and other factors. Subsidy cost is computed as a percentage of the loan value and does not include administrative costs.
+
+While the award amount for a grant or contract is the amount that the recipient gets, for a loan, the award amount is the subsidy cost. This is because the subsidy cost is the actual cost to the government (estimated). Loan Subsidy Cost has a direct budgetary impact and is factored into obligations and outlays when it is positive. Subsidy costs can be positive (indicating that the government is likely to lose money on the loan) or negative (indicating that the government is likely to make money on the loan). A positive Loan Subsidy Cost is usually smaller than the corresponding Face Value, but in certain edge cases it can be over 100% of the face value if the entire loan is written off and the government paid fees to a bank to issue the loan (which are also included in the subsidy cost). Administrative costs of running the loan or loan guarantee program itself are excluded from Loan Subsidy Cost calculation.
+
+**Official definition:** The estimated long-term cost to the Government of a direct loan or loan guarantee, or modification thereof, calculated on a net present value basis, excluding administrative costs.
+
+## Local Area Set Aside
+
+When awarding emergency response contracts during a major disaster or emergency declaration by the President, the government attempts to give preference to local firms. Preference may be given through a local area set-aside or an evaluation preference.
+
+**Official definition:** When awarding emergency response contracts during the term of a major disaster or emergency declaration by the President of the United States under the authority of the Robert T. Stafford Disaster Relief and Emergency Assistance Act (42 U.S.C. 5121, et seq.), preference shall be given, to the extent feasible and practicable, to local firms. Preference may be given through a local area set-aside or an evaluation preference. Note: When the value for the data element 'Multiple or Single Award IDV' is 'Single' on the Referenced IDV, the value for 'Local Area Set Aside' is propagated from the BPA. When the value is 'Multiple' user input is required.
+
+## Main Account Code
+
+This is a 4-digit number that is part of a Treasury Account Symbol (TAS) and Identifies the TAS type and purpose. It cannot be blank.
+
+**Official definition:** The main account code identifies the account in statute.
+
+## Materials, Supplies, Articles & Equip
+
+Indicates whether the transaction is subject to the Materials, Supplies, Articles, & Equip. The clause is 52.222-20 "Contracts for Materials, Supplies, Articles, and Equipment Exceeding $15,000" - that goes with Contracts for Materials, Supplies, Articles, and Equipment Exceeding $15,000 (formerly Walsh-Healey).
+
+## Modification Number
+
+The identifier of an action being reported that indicates the specific subsequent change to the initial award.
+
+## Multi-Agency Contract (MAC)
+
+A Multi-Agency Contract (MAC) is a task-order or delivery-order contract established by one agency for use by government agencies to obtain supplies and services.
+
+## Multiple Award Schedule (MAS)
+
+A listing of contractors that have been awarded a contract by GSA that can be used by all federal agencies. This is also known as a Federal Supply Schedule (FSS).
+
+## Multiple Recipients
+
+A recipient name of "MULTIPLE RECIPIENTS" indicates that the financial assistance award has been aggregated to protect the Personally Identifiable Information (PII) of a collection of individuals. Agencies are prohibited from publishing PII on USAspending. Aggregating involves grouping awards to individuals (typically from the same program and time period) by county (for domestic awards), state (for domestic awards), or country (for foreign awards). These records omit location information that would normally be present (street address and the last 4 digits of the ZIP code) and replace the recipient name with “MULTIPLE RECIPIENTS.” The award summary pages for these records specify the level of aggregation.
+
+## NAICS
+
+NAICS stands for the North American Industrial Classification System. This 6-digit code tells you what industry the work falls into. Each contract record has a NAICS code. That means you can look up how much money the U.S. government spent in a specific industry.
+
+The list of industries and codes is updated every 5 years.
+
+**Official definition:** The identifier and title that represents the North American Industrial Classification System Code assigned to the solicitation and resulting award identifying the industry in which the contract requirements are normally performed
+
+## National Interest Action (NIA)
+
+The National Interest Action (NIA) code categorizes federal contracts that are related to emergency responses or other nationally significant events.
+
+**Official definition:** The National Interest Action values are used to categorize procurement actions related to emergency contingency responses or other nationally significant events. The length of the value is no more than 4 characters. A new NIA value was created to address the COVID-19 pandemic and this value is valid for actions signed between 3/13/2020 and 9/30/2020.
+
+Below are examples of NIA values:
+ - H19M – Hurricane Michael 2019
+ - H19D – Hurricane Dorian 2019
+ - P20C – COVID-19 2020
+
+Note that the Disaster Emergency Fund Code (DEFC) is also used to track COVID-19 spending. However, it is not limited to contracts and is necessarily tied to COVID-19 supplemental appropriations. Thus, awards with the COVID-19 NIA value may not have a COVID-19 DEFC value, and vice versa.
+
+## Non-Federal Funding Amount
+
+The amount of the award funded by non-Federal source(s), in dollars. Program Income (as defined in 2 CFR § 200.1) is not included until such time that Program Income is generated and credited to the agreement.
+
+Award obligation and award outlay amounts (from Files C, D1, and D2) only count dollars spent from federal funding, not any dollars spent from non-federal funding.
+
+## Object Class
+
+Object class is one way to classify financial data in the federal budget. An object class groups obligations by the types of items or services purchased by the federal government. Examples: "Personnel Compensation" and "Equipment"
+
+**Official definition:** Categories in a classification system that presents obligations by the items or services purchased by the Federal Government. Each specific object class is defined in OMB Circular A-11 § 83.6.
+
+(defined in OMB Circular A-11)
+
+## Obligation
+
+When awarding funding, the U.S. government enters a binding agreement called an obligation. The government promises to spend the money, either immediately or in the future. An agency incurs an obligation, for example, when it places an order, signs a contract, awards a grant, purchases a service, or takes other actions that require it to make a payment.
+
+Loan Subsidy Cost has a direct budgetary impact and is factored into obligations and outlays when it is positive.
+
+**Official definition:** Obligation means a legally binding agreement that will result in outlays, immediately or in the future. When you place an order, sign a contract, award a grant, purchase a service, or take other actions that require the Government to make payments to the public or from one Government account to another, you incur an obligation. It is a violation of the Antideficiency Act (31 U.S.C. § 1341(a)) to involve the Federal Government in a contract or obligation for payment of money before an appropriation is made, unless authorized by law. This means you cannot incur obligations in a vacuum; you incur an obligation against budget authority in a Treasury account that belongs to your agency. It is a violation of the Antideficiency Act to incur an obligation in an amount greater than the amount available in the Treasury account that is available. This means that the account must have budget authority sufficient to cover the total of such obligations at the time the obligation is incurred. In addition, the obligation you incur must conform to other applicable provisions of law, and you must be able to support the amounts reported by the documentary evidence required by 31 U.S.C. § 1501. Moreover, you are required to maintain certifications and records showing that the amounts have been obligated (31 U.S.C. § 1108). The following subsections provide additional guidance on when to record obligations for the different types of goods and services or the amount.
+
+
+
+Additional detail is provided in Circular A‐11.
+
+## Ordering Period End Date
+
+For procurement, the date on which, for the award referred to by the action being reported, no additional orders referring to it may be placed. This date applies only to procurement indefinite delivery vehicles (such as indefinite delivery contracts or blanket purchase agreements). Administrative actions related to this award may continue to occur after this date. The period of performance end dates for procurement orders issued under the indefinite delivery vehicle may extend beyond this date.
+
+## Other Budgetary Resources
+
+A subset of budget authority. Most spending by agencies is authorized by appropriation laws; a small amount may come from money not spent in the previous year. The rest is authorized in other ways and grouped together on USAspending.gov as Other Budgetary Resources.
+
+**Official definition:** New borrowing authority, contract authority, and spending authority from offsetting collections provided by Congress in an appropriations act or other legislation, or unobligated balances of budgetary resources made available in previous legislation, to incur obligations and to make outlays.
+
+(defined in OMB Circular A-11)
+
+## Other Financial Assistance
+
+Financial assistance from the Federal Government that is not described by any of the previously-defined assistance types.
+
+## Other Object Class
+
+This major object class includes other miscellaneous charges.
+
+**Official definition:** This major object class covers object classes 91.0 through 99.5. This includes:
+91.0 Unvouchered
+92.0 Undistributed
+94.0 Financial transfers
+99.0 Subtotal, obligations
+99.5 Adjustment for rounding
+
+Each specific object class is defined in OMB Circular A-11 Section 83.6.
+
+## Other Transaction (OT) Indefinite Delivery Vehicle (IDV)
+
+An Other Transaction (OT) Indefinite Delivery Vehicle is a transaction other than a procurement contract, grant, or cooperative agreement. Since this transaction is defined in the negative, it could take unlimited potential forms. This term is often used to refer to transactions designed to:
+
+- Support research & development for homeland security.
+- Advance the development, testing, and deployment of critical homeland security technologies.
+- Speed up prototyping and deployment of technologies addressing homeland security vulnerabilities.
+
+The Department of Homeland Security (DHS) often splits its use of OT's for Research and Prototype Projects.
+
+## Outlay
+
+An outlay occurs when federal money is actually paid out, not just promised to be paid ("obligated").
+
+**Official definition:** Payments made to liquidate an obligation (other than the repayment of debt principal or other disbursements that are “means of financing” transactions). Outlays generally are equal to cash disbursements but also are recorded for cash-equivalent transactions, such as the issuance of debentures to pay insurance claims, and in a few cases are recorded on an accrual basis such as interest on public issues of the public debt. Outlays are the measure of Government spending.
+
+(defined in OMB Circular A-11)
+
+## Parent Award Identification (ID) Number
+
+The identifier of the procurement award under which the specific award is issued, such as a Federal Supply Schedule. This data element currently applies to procurement actions only.
+
+## Parent DUNS
+
+The unique identification number for the ultimate parent of an awardee or recipient. Currently the identifier is the 9-digit number maintained by Dun & Bradstreet as the global parent DUNS® number.
+
+## Period of Performance Current End Date
+
+The date that the award ends, as agreed upon by the parties involved without exercising any pre-determined extension options. Note that the latest transaction for the award (known as the Latest Transaction Action Date) may be different than this date.
+
+**Official definition:** For procurement awards: The contract completion date based on the schedule in the contract. For an initial award, this is the scheduled completion date for the base contract and for any options exercised at time of award. For modifications that exercise options or that shorten (such as termination) or extend the contract period of performance, this is the revised scheduled completion date for the base contract including exercised options. If the award is solely for the purchase of supplies to be delivered, the completion date should correspond to the latest delivery date on the base contract and any exercised options. The completion date does not change to reflect a closeout date.
+
+For grants and cooperative agreements: The Period of Performance is defined in the CFR 200 as the total estimated time interval between the start of an initial Federal award and the planned end date, which may include one or more funded portions, or budget periods. If the end date is revised due to an extension, termination, lack of available funds, or other reason, the current end date will be amended.
+
+For all other financial assistance awards: The current date on which, for the award referred to by the action being reported, awardee effort completes or the award is otherwise ended. Administrative actions related to this award may continue to occur after this date.
+
+Note that the latest transaction for the award (known as the Latest Transaction Action Date) may be different than Period of Performance Current End Date.
+
+## Period of Performance Potential End Date
+
+The date that the award ends, as agreed upon by the parties involved after exercising any pre-determined extension options. Note that the latest transaction for the award (known as the Latest Transaction Action Date) may be different than this date.
+
+Administrative actions related to this award may continue to occur after the Period of Performance Potential End Date.
+
+The Period of Performance Potential End Date does not apply to Contract Indefinite Delivery Vehicles under which Definitive Contracts may be awarded.
+
+## Period of Performance Start Date
+
+The date that the award begins, as agreed upon by the parties involved. Note that the first transaction for the award (known as the Base Transaction Action Date) may be different than this date.
+
+**Official definition:** For procurement awards: Per the FPDS data dictionary, the date that the parties agree will be the starting date for the contract's requirements. This is the period of performance start date for the entire contract period, this date does not reflect period of performance per modification, but rather the start of the entire contract period of performance. This data element does NOT correspond to FAR 43.101 or 52.243 and should not be mapped to those fields in your contract writing systems.
+
+For grants and cooperative agreements: The Period of Performance is defined in the 2 CFR 200 as the total estimated time interval between the start of an initial Federal award and the planned end date, which may include one or more funded portions, or budget periods.
+
+For all other financial assistance awards: The date on which, for the award referred to by the action being reported, awardee effort begins or the award is otherwise effective.
+
+Note that the first transaction for the award (known as the Base Transaction Action Date) may be different than the Period of Performance Start Date.
+
+## Personnel Compensation and Benefits
+
+This major object class includes employee compensation, including salaries, wages, and health benefits, for federal employees. Personnel compensation and benefits apply to full-time and part-time employees, along with military personnel.
+
+**Official definition:** This major object class consists of object classes 11, 12, and 13. This includes:
+11 Personnel compensation
+11.1 Full-time permanent
+11.3 Other than full-time
+permanent
+11.5 Other personnel
+compensation
+11.6 Military personnel -
+basic allowance for
+housing
+11.7 Military personnel
+11.8 Special personal services
+payments
+11.9 Total personnel
+compensation
+12 Personnel benefits
+12.1 Civilian personnel
+benefits
+12.2 Military personnel
+benefits
+13.0 Benefits for former
+personnel
+
+Each specific object class is defined in OMB Circular A-11 Section 83.6.
+
+## Potential Award Amount
+
+The total amount that could be obligated on a contract. This total includes the base plus options amount. For example, if a recipient is awarded $10M on a base contract with 3 option years at $1M each, the potential award amount is $13M.
+
+**Official definition:** For procurement, the total amount that could be obligated on a contract, if the base and all options are exercised.
+
+## Primary Place of Performance
+
+The principal place of business, where the majority of the work is performed. For example, in a manufacturing contract, this would be the main plant where items are produced.
+
+**Official definition:** The address where the predominant performance of the award will be accomplished. The address is made up of four components: City, State Code, and ZIP+4 or Postal Code.
+
+## Primary Place of Performance Congressional District
+
+The congressional district where the principal place of business, where the majority of the work is performed. For example, in a manufacturing contract, this would be the main plant where items are produced.
+
+**Official definition:** U.S. congressional district where the predominant performance of the award will be accomplished. This data element will be derived from the Primary Place of Performance Address.
+
+## Primary Place of Performance Country
+
+The country where the principal place of business, where the majority of the work is performed. For example, in a manufacturing contract, this would be the main plant where items are produced.
+
+**Official definition:** Country code where the predominant performance of the award will be accomplished.
+
+## Prime Award
+
+A prime award is an agreement that the government makes with a non-federal entity for the purpose of carrying out a federal program. The entities receiving the prime award are known as prime recipients.
+
+The term “prime award” can be used as a generic term to describe either transactions or prime award summaries.
+
+**Official definition:** A Prime Award is a a federal award that is either:
+(1) Federal financial assistance that a non-Federal entity receives directly from a Federal awarding agency; or
+(2) The cost-reimbursement contract under the Federal Acquisition Regulations that a non-Federal entity receives directly from a Federal awarding agency.
+(Adapted from 2 CFR §200.38)
+
+## Prime Award Summary
+
+A prime award summary includes all related prime award transactions that share the same prime award unique key. Award Profile pages on USAspending.gov allow users to browse individual prime award summaries, including the list of transactions that constitute the prime award summary, the list of sub-awards funded by the prime award summary, and the list of federal accounts which have funded the prime award summary.
+
+Generally speaking, information from the most recent prime award transaction is applied to the summary-level information in the prime award summary. For example, the award’s recipient name, awarding agency, and period of performance at the summary level is drawn from the latest transaction of that award.
+
+## Prime Recipient
+
+A company, organization, individual, or government entity (i.e., state, local, tribal, or foreign) that receives funding directly from the U.S. government. They receive this funding through an agreement called a prime award. For example, if the Dept. of Transporation is building a bridge, they can award Bridge Company A the contract to carry out the construction. Bridge Company A would be the prime recipient.
+
+**Official definition:** A non-Federal entity that receives a Federal award directly from a Federal awarding agency to carry out an activity under a Federal program.
+
+## Procurement Instrument Identifier (PIID)
+
+A unique identifier assigned to a federal contract, purchase order, basic ordering agreement, basic agreement, and blanket purchase agreement. It is used to track the contract and any modifications or transactions related to it.
+
+**Official definition:** The unique identifier of the specific award being reported.
+
+[Read more in the Federal Acquisition Regulation](https://www.acquisition.gov/far/html/Subpart%204_16.html).
+
+## Product or Service Code (PSC)
+
+A Product or Service Code (PSC) is a 4-character code that identifies the type of product, service, or research & development (R&D) purchased. While NAICS codes identify the industry most relevant to a contract, PSCs tell you what the contract is specifically purchasing. For example, a contract’s NAICS code might point to the “Industrial Building Construction” industry, while that same contract’s PSC points to “Construct Hospitals and Infirmaries.” There are nearly three times as many PSCs (over 2,900) as there are NAICS codes (just over 1000), which in many cases allows a more granular PSC designation than NAICS code designation for a given contract.
+
+All PSC are 4 characters long, but there is an embedded hierarchy in the codes.
+
+- **R&D**: begin with ‘A’ (indicating R&D), followed by a second letter, followed by a number, followed by a number (four levels of hierarchy). Example: AA11.
+
+- **Services**: begin with ‘B’ to ‘Z’ (indicating the subcategory of Service), followed by a number, followed by two letters (four levels of hierarchy if you include the “Service” designation). Example: C1AA
+
+- **Products**: begin with two numbers (indicating the subcategory of Product), followed by two more numbers (three levels of hierarchy if you include the “Product” designation). Example: 1005
+
+**Official definition:** The code that best identifies the product or service procured. Codes are defined in the Product and Service Codes Manual.
+
+## Program Activity
+
+A program activity is a category within an appropriation account. A program activity is a specific activity or project, as listed in the program and financing schedules of the annual budget of the U.S. government.
+
+**Official definition:** A specific activity or project as listed in the program and financing schedules of the annual budget of the United States Government.
+
+According to OMB Circular A-11, The activities should:
+- Clearly indicate the services to be performed or the programs to be conducted;
+- Finance no more than one strategic goal or objective;
+- Distinguish investment, developmental, grant and subsidy, and operating programs; and
+- Relate to administrative control and operation of the agency.
+
+## Program, System, and Equipment Code
+
+A system-generated Department of Defense (DOD) code, also known as the Acquisition Program (AP) Code. This code identifies the DOD program, weapons system, or equipment being acquired. It can be categorized as a Major Defense Acquisition Program (MDAP) or a Major Automated Information System (MAIS).
+
+**Official definition:** Two codes that together identify the program and weapons system or equipment purchased by a DOD agency. The first character is a number 1-4 that identifies the DOD component. The last 3 characters identify that component's program, system, or equipment.
+
+[Read more about this code](https://www.fpds.gov/help/SystemEquipment.htm) on the General Services Administration website.
+
+## Project Grant
+
+Funding of specific projects for a fixed amount of time. Some examples include fellowships, scholarships, research grants, survey grants, and construction grants.
+
+**Official definition:** Project grants provide federal funding for fixed or known periods for specific projects or the delivery of specific services or products.
+
+## Purchase Order
+
+A Purchase Order is an offer by the government established to buy supplies or services, including construction and research and development, upon specified terms and conditions, using simplified acquisition procedures.
+
+## Reason for Modification
+
+Provides information on the type of change made to an award.
+
+**Official definition:** Description (and corresponding code) that provides information on any changes made to the Federal prime award. There are typically multiple actions for each award.
+
+(Note: This definition encompasses current data elements ‘Type of Action’ for financial assistance and ‘Reason for Modification’ for procurement)
+
+## Recipient
+
+A company, organization, individual, or government entity (i.e., state, local, tribal, federal, or foreign), that receives funding from the U.S. government.
+
+## Recipient Congressional District
+
+The congressional district in which the recipient is located.
+
+**Official definition:** The congressional district in which the awardee or recipient is located. This is not a required data element for non-U.S. addresses.
+
+## Recipient Location
+
+Legal business address of the recipient.
+
+**Official definition:** The awardee or recipient’s legal business address where the office represented by the Unique Entity Identifier (as registered in the System for Award Management) is located. In most cases, this should match what the entity has filed with the State in its organizational documents, if required. The address is made up of five components: Address Lines 1 and 2, City, State Code, and ZIP+4 or Postal Code.
+
+## Recipient Name
+
+A recipient is a company, organization, individual, or government entity (i.e., state, local, tribal, federal, or foreign), that received funding by the U.S. government. The recipient name is the same as what's registered in the System for Award Management (SAM.gov). This is usually the official name of the business. For individuals, the term 'Multiple Recipients' is used as the Recipient Name to protect individuals' privacy.
+
+**Official definition:** The name of the awardee or recipient that relates to the unique identifier. For U.S. based companies, this name is what the business ordinarily files in formation documents with individual states (when required).
+
+## Recipient/Business Types
+
+Recipient/Business types are socio-economic and other organizational/business characteristics that are used to categorize federal contractors and other funding recipients. There are many different recipient/business types, and they span for-profit businesses, non-profits, government entities, individuals, and foreign entities. Some examples are:
+
+- Historically Black College or University
+- Veteran-Owned Business
+- Historically Underutilized Business Zone (HUBZone) Firm
+- Sole Proprietorship
+- Foundation
+
+You can search and filter on all recipient types on this site.
+
+**Official definition:** A collection of indicators of different types of recipients based on socio-economic status and organization / business areas.
+
+## Record Type
+
+Code indicating whether an action is an Aggregate Record (Record Type = 1), a Non-aggregate Record (Record Type = 2), or a Non-Aggregate Record to an Individual Recipient with Redacted Personally Identifiable Information (Record Type = 3).
+
+## Redacted Due To PII
+
+A recipient name of "REDACTED DUE TO PII" indicates that the associated financial assistance award was issued to an individual whose name and other Personally Identifiable Information (PII) were redacted, as required by law. Along with masking the individual’s name with “REDACTED DUE TO PII,” these records omit location information that would otherwise be present (street address and the last 4 digits of the ZIP code).
+
+## Set Aside Type
+
+A tool used to award contracts to specific types of businesses. Most set asides reserve contracts for small businesses. Others are more specific, to support small businesses with specific designations, such as veteran owned business or small disadvantaged business types.
+
+**Official definition:** The designator for type of set aside determined for the contract action.
+
+## Simplified Acquisition Procedures (SAP)
+
+For certain types of government purchases between $3,000 and $150,000. These purchases may require less approval and less documentation.
+
+## Solicitation
+
+When an agency needs work done, it can ask for information or bids on the work. These requests are called solicitations. They often come as a RFI (Request for Information) or RFP (Request for Proposal).
+
+## Spending
+
+On this site, the term spending could either describe obligations (amount awarded) or outlays (amount paid out).
+
+## Sub Account Code
+
+Sub Account Code (SUB) is a component of the TAS that identifies a Treasury-defined subdivision of a Federal Account (AID + MAIN). Most Federal Accounts do not have subdivisions. 000 is the default SUB; if 000 is the only SUB under a given Federal Account, it has not been subdivided
+
+**Official definition:** This is a component of the TAS. Identifies a Treasury-defined subdivision of the main account. This field cannot be blank. Sub Account 000 indicates the Parent account.
+
+## Sub-Award
+
+A sub-award is an agreement that a prime recipient makes with another entity to perform a portion of their award. On our website, these recipients are known as sub-recipients. Sub-awards might also be referred to as a sub-contract or a sub-grant. Sub-award amounts are funded by prime award obligations and outlays. In theory, the total value of all sub-award amounts for any given prime award is a subset of the Current Award Amount for that prime award; sub-award amounts generally should not exceed the Current Award Amount for their associated prime award. To avoid double-counting the overall value of a prime award, do not sum up sub-award amounts and prime award obligations or outlays.
+
+**Official definition:** An award provided by a pass-through entity to a subrecipient for the subrecipient to carry out part of a federal award received by the pass-through entity. It does not include payments to a contractor or payments to an individual that is a beneficiary of a federal program. A subaward may be provided through any form of legal agreement, including an agreement that the pass-through entity considers a contract. (2CFR)
+
+## Sub-Recipient
+
+A company, organization, individual, or government entity (i.e., state, local, tribal, or foreign) that receives funding from another recipient of federal funds (a prime recipient), rather than directly from the U.S. government. The sub-recipient may be a sub-contractor or a sub-grantee. For example, the Dept. of Transporation awards Bridge Company A a bridge construction contract. Bridge Company A needs Bridge Company B to supply the steel, so Bridge Company A awards Bridge Company B a sub-award. Bridge Company B is the sub-contractor. On the grants side, University A receives an R&D grant from the National Science Foundation. University A needs University B to perform the initial step in the research, so University A awards University B a sub-award. University B is the sub-grantee.
+
+**Official definition:** A non-Federal entity that receives a sub-award from a pass-through entity to carry out part of a federal program; but does not include an individual that is the beneficiary of such program. (grants.gov)
+
+## Submission Period
+
+The submission period shows when federal agencies submit their financial data. It is displayed as a fiscal year (e.g., “FY 2020” or “FY20” for fiscal year 2020, covering October 2019 through September 2020) followed by a month (e.g., “P01” for October, which is the first month of the fiscal year) or quarter (e.g., “Q1” for the first quarter of the fiscal year, covering October through December). For example, “FY19 P10” indicates a submission whose data covers the period of July 2019.
+
+Starting with the June 2020 reporting period, most federal agencies began submitting their account data (Files A, B, and C) to the Treasury DATA Act Broker on a monthly basis rather than on the previous quarterly schedule. As of October 2021 (FY22 Q1), all agencies are required to report on a monthly basis. More information about the agency account data reporting policy is found in OMB’s Memorandum M-20-21 (Appendix A, Section III).
+
+## Task Order Contract
+
+An Indefinite Quantity Contract for services (not supplies) is sometimes referred to as a Task Order Contract. With this type of contract, the government promises to buy services over a period of time from a vendor. Instead of an exact amount, it sets a range with a minimum and maximum.
+
+## Transaction
+
+A transaction can be the initial contract, grant, loan, or insurance award or any amendment or modification to that award.
+
+## Transaction Description
+
+A brief description of the purpose of the transaction.
+
+## Treasury Account Symbol (TAS)
+
+Treasury and OMB assign a code to each appropriation, receipt, or fund account. This code is similar to a bank account number. It helps identify financial transactions in the federal government. It also aids in reporting accuracy. TAS are sometimes referred as ‘program source’ in legislation. On this website, we group each set of Treasury Accounts that share an Agency Identifier and Main Account Code into a "Federal Account".
+
+Seven components make up the TAS:
+
+- Allocation Transfer Agency Identifier (ex. 089)
+- Agency Identifier (ex. 020)
+- Beginning Period of Availability (ex. 2017)
+- Ending Period of Availability (ex. 2018)
+- Availability Type Code (used if there are not specific beginning/ending years) (ex. X)
+- Main Account Code (ex. 0114)
+- Sub Account Code (ex. 000)
+
+Example TAS:
+
+- 089-020-2017/2018-0114-000
+- 089-020-2017/2017-0114-000
+- 089-020-X-0114-000
+
+**Official definition:** Treasury Account Symbol: The account identification codes assigned by the Department of the Treasury to individual appropriation, receipt, or other fund accounts. All financial transactions of the Federal Government are classified by TAS for reporting to the Department of the Treasury and the Office of Management and Budget.
+
+(defined in OMB Circular A-11)
+
+## Ultimate Parent Legal Entity Name
+
+The name of the ultimate parent of the awardee or recipient.
+
+## Unique Entity Identifier (UEI)
+
+The Unique Entity Identifier (UEI) for an awardee or recipient is an alphanumeric code created in the System for Award Management (SAM.gov) that is used to uniquely identify specific commercial, nonprofit, or business entities registered to do business with the federal government.
+
+## Unlinked Award
+
+There are two distinct datasets transmitted to USAspending for agency awards—File C and Files D. File C is submitted and published on the site on a monthly or quarterly basis from audited agency financial systems. File D1 (procurement) and File D2 (financial assistance) data is generated from award reporting data submitted by agencies to other systems and updated on USAspending as frequently as daily. Because these data originate from different communities and systems within agencies that are subject to different policies and reporting requirements, there are sometimes gaps between the awards captured in each dataset.
+
+Unlinked awards lack a shared award ID that allows a match between financial system data and award reporting data. As a result, such awards only show up in some parts of the site and are missing their full context. For example, awards found in File C but not in File D lack recipient and CFDA Program information and thus, will not have an Award Summary page.
+
+## Unobligated Balance
+
+The amount of money out of an account that has yet to be awarded or obligated (promised to be spent).
+
+**Official definition:** Unobligated balance means the cumulative amount of budget authority that remains available for obligation under law in unexpired accounts at a point in time. The term “expired balances available for adjustment only” refers to unobligated amounts in expired accounts.
+
+
+
+Additional detail is provided in Circular A‐11.
+
+## Unreported Data
+
+There are various reasons financial or award data is not reported by agencies or otherwise available to USAspending.gov at a given time. These include, but are not limited to, timing of data availability, or sensitive data that is not subject to submission. Where possible, USAspending.gov advises readers that other information exists that cannot be detailed.
+
+## URI
+
+URI stands for Unique Record Identifier. A URI is an agency-defined identifier that is unique for every financial assistance action reported by that agency. USAspending.gov uses URI as the Award ID for aggregate records.
diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/overview.md b/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/overview.md
new file mode 100644
index 00000000..1f66ac97
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/overview.md
@@ -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;
+```
diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/tables/spending.md b/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/tables/spending.md
new file mode 100644
index 00000000..02b119b7
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/schema/tables/spending.md
@@ -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.
diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py b/examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py
new file mode 100644
index 00000000..cec79428
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/setup_db.py
@@ -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()
diff --git a/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py b/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py
new file mode 100644
index 00000000..2b736197
--- /dev/null
+++ b/examples/sandbox/extensions/daytona/usaspending_text2sql/sql_capability.py
@@ -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
diff --git a/examples/sandbox/extensions/e2b_runner.py b/examples/sandbox/extensions/e2b_runner.py
new file mode 100644
index 00000000..675fafa0
--- /dev/null
+++ b/examples/sandbox/extensions/e2b_runner.py
@@ -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 ` 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,
+ )
+ )
diff --git a/examples/sandbox/extensions/modal_runner.py b/examples/sandbox/extensions/modal_runner.py
new file mode 100644
index 00000000..53fbf46b
--- /dev/null
+++ b/examples/sandbox/extensions/modal_runner.py
@@ -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,
+ )
+ )
diff --git a/examples/sandbox/extensions/runloop/__init__.py b/examples/sandbox/extensions/runloop/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/examples/sandbox/extensions/runloop/capabilities.py b/examples/sandbox/extensions/runloop/capabilities.py
new file mode 100644
index 00000000..941af3f3
--- /dev/null
+++ b/examples/sandbox/extensions/runloop/capabilities.py
@@ -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()))
diff --git a/examples/sandbox/extensions/runloop/runner.py b/examples/sandbox/extensions/runloop/runner.py
new file mode 100644
index 00000000..bb7f0dd9
--- /dev/null
+++ b/examples/sandbox/extensions/runloop/runner.py
@@ -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,
+ )
+ )
diff --git a/examples/sandbox/extensions/temporal/README.md b/examples/sandbox/extensions/temporal/README.md
new file mode 100644
index 00000000..57822e9b
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/README.md
@@ -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 ` | 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.
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/__init__.py b/examples/sandbox/extensions/temporal/_vendored_plugin/__init__.py
new file mode 100644
index 00000000..ed851c81
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/__init__.py
@@ -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 `_ 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",
+]
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/_invoke_model_activity.py b/examples/sandbox/extensions/temporal/_vendored_plugin/_invoke_model_activity.py
new file mode 100644
index 00000000..31d7a333
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/_invoke_model_activity.py
@@ -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
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/_openai_runner.py b/examples/sandbox/extensions/temporal/_vendored_plugin/_openai_runner.py
new file mode 100644
index 00000000..c8583b93
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/_openai_runner.py
@@ -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
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/_temporal_model_stub.py b/examples/sandbox/extensions/temporal/_vendored_plugin/_temporal_model_stub.py
new file mode 100644
index 00000000..bb811416
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/_temporal_model_stub.py
@@ -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")
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/_temporal_openai_agents.py b/examples/sandbox/extensions/temporal/_vendored_plugin/_temporal_openai_agents.py
new file mode 100644
index 00000000..9f19e6dc
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/_temporal_openai_agents.py
@@ -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()
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/patch_plugin.justfile b/examples/sandbox/extensions/temporal/_vendored_plugin/patch_plugin.justfile
new file mode 100644
index 00000000..5a63e586
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/patch_plugin.justfile
@@ -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."
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/__init__.py b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/__init__.py
new file mode 100644
index 00000000..fdfc85c6
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/__init__.py
@@ -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.
+"""
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_sandbox_client_provider.py b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_sandbox_client_provider.py
new file mode 100644
index 00000000..3fff371c
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_sandbox_client_provider.py
@@ -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()
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_activity_models.py b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_activity_models.py
new file mode 100644
index 00000000..e5c16f71
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_activity_models.py
@@ -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
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_activities.py b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_activities.py
new file mode 100644
index 00000000..93e3f1b6
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_activities.py
@@ -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,
+ ]
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_client.py b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_client.py
new file mode 100644
index 00000000..0113f572
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_client.py
@@ -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)
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_session.py b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_session.py
new file mode 100644
index 00000000..4b44c649
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/sandbox/_temporal_sandbox_session.py
@@ -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,
+ )
diff --git a/examples/sandbox/extensions/temporal/_vendored_plugin/workflow.py b/examples/sandbox/extensions/temporal/_vendored_plugin/workflow.py
new file mode 100644
index 00000000..0cfa1bcd
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_vendored_plugin/workflow.py
@@ -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."""
diff --git a/examples/sandbox/extensions/temporal/_worker_setup.py b/examples/sandbox/extensions/temporal/_worker_setup.py
new file mode 100644
index 00000000..14dbea7f
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/_worker_setup.py
@@ -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}")
diff --git a/examples/sandbox/extensions/temporal/justfile b/examples/sandbox/extensions/temporal/justfile
new file mode 100644
index 00000000..7561ccbd
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/justfile
@@ -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
diff --git a/examples/sandbox/extensions/temporal/temporal_sandbox_agent.py b/examples/sandbox/extensions/temporal/temporal_sandbox_agent.py
new file mode 100644
index 00000000..bdc511c2
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/temporal_sandbox_agent.py
@@ -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())
diff --git a/examples/sandbox/extensions/temporal/temporal_sandbox_tui.py b/examples/sandbox/extensions/temporal/temporal_sandbox_tui.py
new file mode 100644
index 00000000..29b9c38f
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/temporal_sandbox_tui.py
@@ -0,0 +1,1204 @@
+# mypy: ignore-errors
+# standalone example with sys.path sibling imports that mypy cannot follow
+"""Textual TUI for the Temporal Sandbox agent conversation client.
+
+Sessions are managed entirely via Temporal — no filesystem persistence.
+A central SessionManagerWorkflow tracks all active agent sessions. The
+TUI connects to it on startup to list, create, resume, and destroy sessions.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from datetime import timezone
+from pathlib import Path
+
+from rich.markdown import Markdown
+from rich.text import Text
+from temporal_sandbox_agent import TurnState
+from temporal_session_manager import (
+ MANAGER_WORKFLOW_ID,
+ BackendConfig,
+ CreateSessionRequest,
+ DaytonaBackendConfig,
+ DockerBackendConfig,
+ E2BBackendConfig,
+ ForkSessionRequest,
+ LocalBackendConfig,
+ RenameRequest,
+ SessionInfo,
+ SessionManagerWorkflow,
+ SwitchBackendRequest,
+)
+from temporalio.client import Client, WorkflowHandle
+from temporalio.contrib.openai_agents import OpenAIAgentsPlugin
+from temporalio.exceptions import WorkflowAlreadyStartedError
+from textual import work
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical, VerticalScroll
+from textual.screen import ModalScreen
+from textual.widgets import (
+ Button,
+ Footer,
+ Header,
+ Input,
+ OptionList,
+ Static,
+ Tree,
+)
+from textual.widgets.option_list import Option
+
+NEW_SESSION_ID = "__new__"
+NEW_FROM_SNAPSHOT_ID = "__new_from_snapshot__"
+
+SLASH_COMMANDS = [
+ ("/title ", "Rename the current session"),
+ ("/fork [title]", "Fork this session into a new one"),
+ ("/switch [backend]", "Switch sandbox backend (daytona/local)"),
+ ("/done", "Exit the session"),
+]
+
+
+class ToolDetailModal(ModalScreen):
+ """Full-screen modal showing tool call command and output."""
+
+ BINDINGS = [("escape", "dismiss", "Close")]
+
+ def __init__(self, title: str, body: str) -> None:
+ super().__init__()
+ self._title = title
+ self._body = body
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="tool-modal"):
+ with Vertical(id="tool-modal-box"):
+ yield Static(self._title, id="tool-modal-title")
+ with VerticalScroll(id="tool-modal-scroll"):
+ yield Static(self._body, id="tool-modal-body")
+
+ def action_dismiss(self) -> None:
+ self.app.pop_screen()
+
+
+class ToolLine(Static):
+ """A clickable one-line tool call summary in the chat flow."""
+
+ def __init__(self, title: str, body: str, **kwargs) -> None:
+ super().__init__(title, classes="tool-line", **kwargs)
+ self._title = title
+ self._body = body
+
+ def on_click(self) -> None:
+ self.app.push_screen(ToolDetailModal(self._title, self._body))
+
+
+class ConversationApp(App):
+ """Textual chat UI backed by Temporal workflows.
+
+ On startup the app connects to the session manager, presents a session
+ picker, and then enters the chat loop. On exit the user chooses to
+ keep the session alive (detach) or destroy it.
+ """
+
+ TITLE = "Sandbox Agent (live)"
+ SUB_TITLE = "Temporal Workflow"
+
+ CSS = """
+ #chat {
+ height: 1fr;
+ border: round $accent;
+ margin: 1 2;
+ padding: 1 2;
+ scrollbar-gutter: stable;
+ }
+ #chat > Static {
+ margin: 0;
+ padding: 0;
+ }
+ .tool-line {
+ height: 1;
+ padding: 0 1;
+ color: $text-muted;
+ }
+ .tool-line:hover {
+ background: $surface;
+ color: $text;
+ }
+ #tool-modal {
+ align: center middle;
+ }
+ #tool-modal-box {
+ width: 90%;
+ height: 80%;
+ border: round $accent;
+ background: $surface;
+ padding: 1 2;
+ }
+ #tool-modal-title {
+ height: 1;
+ width: 1fr;
+ text-style: bold;
+ margin: 0 0 1 0;
+ }
+ #tool-modal-scroll {
+ height: 1fr;
+ }
+ #tool-modal-body {
+ height: auto;
+ }
+ #status-bar {
+ height: 1;
+ padding: 0 2;
+ background: $surface;
+ color: $text;
+ layout: horizontal;
+ }
+ #liveness {
+ width: auto;
+ }
+ #activity {
+ width: auto;
+ margin: 0 0 0 2;
+ }
+ Input {
+ margin: 0 2 1 2;
+ }
+ #slash-menu {
+ display: none;
+ height: auto;
+ max-height: 8;
+ margin: 0 2;
+ background: $surface;
+ border: round $accent;
+ }
+ #session-picker {
+ height: 1fr;
+ margin: 1 2;
+ border: round $accent;
+ padding: 1;
+ }
+ #approval-bar {
+ height: auto;
+ margin: 0 2 1 2;
+ layout: vertical;
+ }
+ #approval-label {
+ width: 1fr;
+ padding: 0 1 1 1;
+ }
+ #approval-buttons {
+ height: auto;
+ align-horizontal: center;
+ }
+ #approval-buttons Button {
+ margin: 0 1;
+ }
+ #exit-bar {
+ height: auto;
+ margin: 0 2 1 2;
+ layout: vertical;
+ }
+ #exit-label {
+ width: 1fr;
+ padding: 0 1 1 1;
+ }
+ #exit-buttons {
+ height: auto;
+ align-horizontal: center;
+ }
+ #exit-buttons Button {
+ margin: 0 1;
+ }
+ #fork-bar {
+ height: auto;
+ margin: 0 2 1 2;
+ layout: vertical;
+ }
+ #fork-label {
+ width: 1fr;
+ padding: 0 1 1 1;
+ }
+ #fork-buttons {
+ height: auto;
+ align-horizontal: center;
+ }
+ #fork-buttons Button {
+ margin: 0 1;
+ }
+ #snapshot-picker {
+ height: 1fr;
+ margin: 1 2;
+ border: round $accent;
+ padding: 1;
+ }
+ #backend-picker {
+ height: auto;
+ margin: 1 2;
+ layout: vertical;
+ }
+ #backend-label {
+ width: 1fr;
+ padding: 0 1 1 1;
+ }
+ #backend-buttons {
+ height: auto;
+ align-horizontal: center;
+ }
+ #backend-buttons Button {
+ margin: 0 1;
+ }
+ #workspace-picker {
+ height: auto;
+ margin: 1 2;
+ layout: vertical;
+ }
+ #workspace-label {
+ width: 1fr;
+ padding: 0 1 1 1;
+ }
+ #workspace-input {
+ margin: 0 2 1 2;
+ }
+ #workspace-buttons {
+ height: auto;
+ align-horizontal: center;
+ }
+ #workspace-buttons Button {
+ margin: 0 1;
+ }
+ """
+
+ BINDINGS = [
+ Binding("ctrl+c", "quit_graceful", "Quit", priority=True),
+ ]
+
+ def __init__(
+ self,
+ *,
+ workflow_cls: type,
+ task_queue: str,
+ cwd: str,
+ ) -> None:
+ super().__init__()
+ self._workflow_cls = workflow_cls
+ self._task_queue = task_queue
+ self._cwd = cwd
+ self._handle: WorkflowHandle | None = None
+ self._manager_handle: WorkflowHandle | None = None
+ self._temporal_client: Client | None = None
+ self._current_workflow_id: str | None = None
+ self._poll_timer = None
+ self._last_paused: bool = False
+ self._pending_fork_title: str | None = None
+ self._cached_sessions: list[SessionInfo] = []
+ self._current_backend: str = "daytona"
+ self._current_turn_id: int = 0
+ self._pending_backend_action: str = "new_session" # "new_session" or "switch"
+
+ async def _backfill_snapshot_ids(self, sessions: list[SessionInfo]) -> None:
+ """Query each workflow's live snapshot ID concurrently.
+
+ Fills in ``snapshot_id`` on SessionInfo objects that don't already
+ have one (e.g. sessions created fresh, before any fork/persist).
+ """
+ assert self._temporal_client is not None
+ missing = [s for s in sessions if not s.snapshot_id]
+ if not missing:
+ return
+
+ async def _fetch(s: SessionInfo) -> None:
+ try:
+ handle = self._temporal_client.get_workflow_handle(s.workflow_id) # type: ignore[union-attr]
+ sid = await handle.query(self._workflow_cls.get_snapshot_id)
+ if sid:
+ s.snapshot_id = sid
+ except Exception:
+ pass
+
+ await asyncio.gather(*[_fetch(s) for s in missing])
+
+ # -- Status helpers -----------------------------------------------------
+
+ def _set_liveness(self, text: str | Text) -> None:
+ """Update the persistent liveness indicator (Active / Paused)."""
+ self.query_one("#liveness", Static).update(text)
+
+ def _set_activity(self, text: str | Text = "") -> None:
+ """Update the transient activity indicator (Thinking / Approval / Error).
+
+ Pass empty string to clear."""
+ self.query_one("#activity", Static).update(text)
+
+ # -- Chat helpers -------------------------------------------------------
+
+ def _chat_write(self, content) -> None:
+ """Append a renderable to the chat scroll area."""
+ chat = self.query_one("#chat", VerticalScroll)
+ chat.mount(Static(content))
+ chat.scroll_end(animate=False)
+
+ def _chat_clear(self) -> None:
+ """Remove all children from the chat scroll area."""
+ chat = self.query_one("#chat", VerticalScroll)
+ chat.remove_children()
+
+ @staticmethod
+ def _tool_call_title(tc) -> str:
+ """Format a one-line title for a tool call Collapsible."""
+ icon = "\u2713" if tc.status == "completed" else "\u23f3"
+ full_text = tc.arguments
+ try:
+ args = json.loads(tc.arguments)
+ if "commands" in args:
+ cmds = args["commands"]
+ full_text = "; ".join(cmds) if cmds else "(empty)"
+ elif "command" in args:
+ full_text = args["command"]
+ except (json.JSONDecodeError, TypeError):
+ pass
+ lines = full_text.split("\n")
+ first_line = lines[0]
+ if len(first_line) > 80:
+ first_line = first_line[:77] + "..."
+ extra = len(lines) - 1
+ suffix = f" [... +{extra} lines]" if extra > 0 else ""
+ return f"{icon} {tc.tool_name}: {first_line}{suffix}"
+
+ @staticmethod
+ def _tool_call_body(tc) -> str:
+ """Format the expanded body of a tool call Collapsible."""
+ parts = []
+ try:
+ args = json.loads(tc.arguments)
+ parts.append(json.dumps(args, indent=2))
+ except (json.JSONDecodeError, TypeError):
+ parts.append(tc.arguments)
+ if tc.status == "completed":
+ output = tc.output or "(empty)"
+ parts.append(f"\n--- output ---\n{output}")
+ elif tc.status == "running":
+ parts.append("\n\u23f3 Running...")
+ else:
+ parts.append("\n\u23f3 Pending...")
+ return "\n".join(parts)
+
+ async def _render_live_tool_calls(self, state: TurnState) -> None:
+ """Create or update ToolLine widgets for live tool calls."""
+ chat = self.query_one("#chat", VerticalScroll)
+ for tc in state.tool_calls:
+ widget_id = "tc_" + "".join(c if c.isalnum() else "_" for c in tc.call_id)
+ title = self._tool_call_title(tc)
+ body = self._tool_call_body(tc)
+ existing = self.query(f"#{widget_id}")
+ if existing:
+ line = existing.first(ToolLine)
+ line.update(title)
+ line._body = body
+ else:
+ await chat.mount(ToolLine(title, body, id=widget_id))
+ chat.scroll_end(animate=False)
+
+ # -- Layout -------------------------------------------------------------
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Tree("Sessions", id="session-picker")
+ yield Tree("Pick a source session", id="snapshot-picker")
+ with Vertical(id="backend-picker"):
+ yield Static("Choose sandbox backend:", id="backend-label")
+ with Horizontal(id="backend-buttons"):
+ yield Button("Daytona (cloud)", id="btn-backend-daytona", variant="primary")
+ yield Button("Docker", id="btn-backend-docker", variant="primary")
+ yield Button("E2B (cloud)", id="btn-backend-e2b", variant="primary")
+ yield Button("Local (unix)", id="btn-backend-local", variant="warning")
+ with Vertical(id="workspace-picker"):
+ yield Static(
+ "Workspace root (agent files will be created here):",
+ id="workspace-label",
+ )
+ yield Input(id="workspace-input", placeholder="/absolute/path/to/workspace")
+ with Horizontal(id="workspace-buttons"):
+ yield Button("Accept", id="btn-workspace-accept", variant="success")
+ yield Button("Cancel", id="btn-workspace-cancel", variant="error")
+ yield VerticalScroll(id="chat")
+ with Vertical(id="approval-bar"):
+ yield Static("", id="approval-label")
+ with Horizontal(id="approval-buttons"):
+ yield Button("Approve", id="btn-approve", variant="success")
+ yield Button("Deny", id="btn-deny", variant="error")
+ with Vertical(id="fork-bar"):
+ yield Static("", id="fork-label")
+ with Horizontal(id="fork-buttons"):
+ yield Button("Copy snapshot", id="btn-fork-copy", variant="success")
+ yield Button("Share snapshot", id="btn-fork-share", variant="warning")
+ with Vertical(id="exit-bar"):
+ yield Static("Keep this session alive for later?", id="exit-label")
+ with Horizontal(id="exit-buttons"):
+ yield Button("Keep Alive", id="btn-keep", variant="success")
+ yield Button("Destroy", id="btn-destroy", variant="error")
+ yield OptionList(id="slash-menu")
+ yield Input(placeholder="Connecting to Temporal...", disabled=True, id="chat-input")
+ with Horizontal(id="status-bar"):
+ yield Static("Connecting...", id="liveness")
+ yield Static("", id="activity")
+ yield Footer()
+
+ async def on_mount(self) -> None:
+ # Start in session-picker mode: hide chat UI
+ self.query_one("#chat").display = False
+ self.query_one("#chat-input", Input).display = False
+ self.query_one("#approval-bar").display = False
+ self.query_one("#fork-bar").display = False
+ self.query_one("#exit-bar").display = False
+ self.query_one("#snapshot-picker").display = False
+ self.query_one("#backend-picker").display = False
+ self.query_one("#workspace-picker").display = False
+ self._init_temporal()
+
+ # -- Phase 1: Connect to Temporal and populate session picker -----------
+
+ @work
+ async def _init_temporal(self) -> None:
+ tree = self.query_one("#session-picker", Tree)
+
+ try:
+ plugin = OpenAIAgentsPlugin()
+ self._temporal_client = await Client.connect(
+ "localhost:7233",
+ plugins=[plugin],
+ )
+ except Exception as e:
+ self._set_liveness(f"Connection failed: {e}")
+ return
+
+ # Ensure the session manager singleton is running
+ try:
+ self._manager_handle = await self._temporal_client.start_workflow(
+ SessionManagerWorkflow.run,
+ id=MANAGER_WORKFLOW_ID,
+ task_queue=self._task_queue,
+ )
+ except WorkflowAlreadyStartedError:
+ self._manager_handle = self._temporal_client.get_workflow_handle(MANAGER_WORKFLOW_ID)
+
+ # Query existing sessions, backfill live snapshot IDs, and build the tree
+ sessions = await self._manager_handle.query(SessionManagerWorkflow.list_sessions)
+ await self._backfill_snapshot_ids(sessions)
+ self._populate_session_tree(tree, sessions)
+
+ self._set_liveness("Select a session")
+ tree.root.expand_all()
+ tree.focus()
+
+ # Distinct background colors for snapshot badges — chosen for
+ # readability on both light and dark terminal themes.
+ _SNAPSHOT_COLORS = [
+ ("on dark_green", "bold white"),
+ ("on dark_blue", "bold white"),
+ ("on dark_magenta", "bold white"),
+ ("on dark_cyan", "bold white"),
+ ("on dark_red", "bold white"),
+ ("on yellow", "bold black"),
+ ("on dodger_blue2", "bold white"),
+ ("on deep_pink4", "bold white"),
+ ("on orange3", "bold black"),
+ ("on chartreuse4", "bold white"),
+ ]
+
+ def _populate_session_tree(self, tree: Tree, sessions: list) -> None:
+ """Build a nested tree from sessions with parent/child relationships."""
+ tree.root.remove_children()
+ self._cached_sessions = list(sessions)
+
+ # Index sessions by workflow_id and group children by parent
+ by_id: dict[str, object] = {}
+ children_of: dict[str | None, list] = {None: []}
+ for s in sessions:
+ by_id[s.workflow_id] = s
+ parent = s.parent_workflow_id
+ # If the parent was destroyed, treat this as a root session
+ if parent and parent not in {si.workflow_id for si in sessions}:
+ parent = None
+ children_of.setdefault(parent, [])
+ children_of[parent].append(s)
+
+ # Build a stable color mapping for unique snapshot IDs
+ unique_snap_ids: list[str] = []
+ seen: set[str] = set()
+ for s in sessions:
+ if s.snapshot_id and s.snapshot_id not in seen:
+ unique_snap_ids.append(s.snapshot_id)
+ seen.add(s.snapshot_id)
+ snap_color_map: dict[str, tuple[str, str]] = {}
+ for i, sid in enumerate(unique_snap_ids):
+ snap_color_map[sid] = self._SNAPSHOT_COLORS[i % len(self._SNAPSHOT_COLORS)]
+
+ def _format_label(s: SessionInfo) -> Text:
+ utc_time = s.created_at.replace(tzinfo=timezone.utc)
+ created = utc_time.astimezone().strftime("%Y-%m-%d %I:%M %p")
+
+ label = Text()
+ label.append(f"{s.title} ")
+ label.append(f"({created})", style="dim")
+
+ if s.backend:
+ label.append(f" [{s.backend.type}]", style="bold dim")
+
+ if s.snapshot_id:
+ short = s.snapshot_id[:8]
+ bg, fg = snap_color_map[s.snapshot_id]
+ label.append(" ")
+ label.append(f" {short} ", style=f"{fg} {bg}")
+
+ return label
+
+ def _add_children(parent_node, parent_id: str | None) -> None:
+ for s in children_of.get(parent_id, []):
+ label = _format_label(s)
+ if children_of.get(s.workflow_id):
+ branch = parent_node.add(label, data=s.workflow_id)
+ _add_children(branch, s.workflow_id)
+ else:
+ parent_node.add_leaf(label, data=s.workflow_id)
+
+ _add_children(tree.root, None)
+ tree.root.add_leaf("+ New Session", data=NEW_SESSION_ID)
+ if sessions:
+ tree.root.add_leaf("+ New from snapshot...", data=NEW_FROM_SNAPSHOT_ID)
+
+ # -- Session selection --------------------------------------------------
+
+ async def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
+ node_data = event.node.data
+ if node_data is None:
+ return
+
+ tree_id = event.node.tree.id
+
+ # Handle snapshot picker selection (choosing source for "new from snapshot")
+ if tree_id == "snapshot-picker":
+ self.query_one("#snapshot-picker").display = False
+ self._create_session_from_snapshot(str(node_data))
+ return
+
+ # Handle main session picker
+ self.query_one("#session-picker").display = False
+
+ if node_data == NEW_SESSION_ID:
+ self._pending_backend_action = "new_session"
+ self._show_backend_picker()
+ return
+ elif node_data == NEW_FROM_SNAPSHOT_ID:
+ self._show_snapshot_source_picker()
+ else:
+ self._resume_session(str(node_data))
+
+ def _show_backend_picker(self) -> None:
+ """Show the backend selection buttons."""
+ self.query_one("#backend-picker").display = True
+ self._set_liveness("Choose a sandbox backend")
+
+ def _on_backend_chosen(self, backend: BackendConfig) -> None:
+ """Dispatch after the backend picker completes."""
+ if self._pending_backend_action == "switch":
+ self._switch_backend(backend)
+ elif self._pending_backend_action == "fork":
+ self._fork_session(self._pending_fork_title, backend)
+ self._pending_fork_title = None
+ else:
+ self._create_new_session(backend=backend)
+
+ def _show_snapshot_source_picker(self) -> None:
+ """Show a sub-tree of sessions to pick a snapshot source from."""
+ tree = self.query_one("#snapshot-picker", Tree)
+ tree.root.remove_children()
+ for s in self._cached_sessions:
+ utc_time = s.created_at.replace(tzinfo=timezone.utc)
+ created = utc_time.astimezone().strftime("%Y-%m-%d %I:%M %p")
+ tree.root.add_leaf(f"{s.title} ({created})", data=s.workflow_id)
+ tree.root.expand_all()
+ tree.display = True
+ self._set_liveness("Pick a session to start from")
+ tree.focus()
+
+ @work
+ async def _create_new_session(
+ self,
+ backend: BackendConfig | None = None,
+ ) -> None:
+ if backend is None:
+ backend = DaytonaBackendConfig()
+ self.query_one("#chat").display = True
+ self._set_liveness("Creating session...")
+ self._chat_write(Text(f"Starting new {backend.type} session...\n", style="yellow"))
+
+ assert self._manager_handle is not None
+ assert self._temporal_client is not None
+ try:
+ workflow_id: str = await self._manager_handle.execute_update(
+ SessionManagerWorkflow.create_session,
+ CreateSessionRequest(cwd=self._cwd, backend=backend),
+ )
+ except Exception as e:
+ self._chat_write(Text(f"Failed to create session: {e}", style="bold red"))
+ self._set_liveness("Error")
+ return
+
+ self._current_workflow_id = workflow_id
+ self._current_backend = backend.type
+ self._handle = self._temporal_client.get_workflow_handle(workflow_id)
+ self._current_turn_id = 0
+ self._set_session_title(f"Session {workflow_id[-8:]}")
+
+ self._chat_write(Text(f"Session started: {workflow_id}\n", style="green"))
+ self._switch_to_chat()
+
+ @work
+ async def _create_session_from_snapshot(self, source_workflow_id: str) -> None:
+ self.query_one("#chat").display = True
+ self._set_liveness("Creating session from snapshot...")
+ self._chat_write(Text("Creating session from existing snapshot...\n", style="yellow"))
+
+ assert self._manager_handle is not None
+ assert self._temporal_client is not None
+ try:
+ workflow_id: str = await self._manager_handle.execute_update(
+ SessionManagerWorkflow.fork_session,
+ ForkSessionRequest(source_workflow_id=source_workflow_id),
+ )
+ except Exception as e:
+ self._chat_write(Text(f"Failed to create session: {e}", style="bold red"))
+ self._set_liveness("Error")
+ return
+
+ self._current_workflow_id = workflow_id
+ self._handle = self._temporal_client.get_workflow_handle(workflow_id)
+ self._current_turn_id = 0
+ self._set_session_title(f"Session {workflow_id[-8:]}")
+
+ self._chat_write(Text(f"Session started from snapshot: {workflow_id}\n", style="green"))
+ self._switch_to_chat()
+
+ @work
+ async def _resume_session(self, workflow_id: str) -> None:
+ self.query_one("#chat").display = True
+ self._set_liveness("Resuming session...")
+
+ assert self._temporal_client is not None
+ self._current_workflow_id = workflow_id
+ self._handle = self._temporal_client.get_workflow_handle(workflow_id)
+
+ # Sync turn_id so we don't mistake prior "complete" as a new response
+ try:
+ state = await self._handle.query(self._workflow_cls.get_turn_state)
+ self._current_turn_id = state.turn_id
+ except Exception:
+ self._current_turn_id = 0
+
+ # Replay conversation history from the workflow
+ try:
+ history: list[dict] = await self._handle.query(self._workflow_cls.get_history)
+ self._render_history(history)
+ except Exception as e:
+ self._chat_write(Text(f"Could not load history: {e}", style="yellow"))
+
+ # Look up the session title and backend from the manager
+ assert self._manager_handle is not None
+ try:
+ sessions = await self._manager_handle.query(SessionManagerWorkflow.list_sessions)
+ for s in sessions:
+ if s.workflow_id == workflow_id:
+ self._set_session_title(s.title)
+ self._current_backend = s.backend.type
+ break
+ except Exception:
+ self._set_session_title(workflow_id[-8:])
+
+ self._chat_write(Text(f"Resumed session: {workflow_id}\n", style="green"))
+ self._switch_to_chat()
+
+ def _set_session_title(self, title: str) -> None:
+ """Update the header to show the active session title."""
+ self.sub_title = title
+
+ def _switch_to_chat(self) -> None:
+ """Transition from session picker to chat mode."""
+ input_w = self.query_one("#chat-input", Input)
+ input_w.display = True
+ input_w.placeholder = "Type a message, or / for commands..."
+ input_w.disabled = False
+ input_w.focus()
+ self._set_liveness(Text(f"● Active [{self._current_backend}]", style="green"))
+ self._set_activity()
+ self._poll_timer = self.set_interval(3, self._poll_liveness)
+
+ def _render_history(self, history: list[dict]) -> None:
+ """Replay conversation history returned by the workflow query."""
+ for entry in history:
+ if entry.get("role") == "user":
+ self._chat_write(Text(f"> {entry['content']}", style="bold cyan"))
+ elif entry.get("role") == "assistant":
+ self._chat_write(Markdown(entry["content"]))
+ if history:
+ self._chat_write(Text("--- session restored ---\n", style="dim"))
+
+ # -- Liveness polling ---------------------------------------------------
+
+ @work(exclusive=True, group="liveness")
+ async def _poll_liveness(self) -> None:
+ """Query the workflow's paused state and update the status bar."""
+ if self._handle is None:
+ return
+ try:
+ paused = await self._handle.query(self._workflow_cls.is_paused)
+ except Exception:
+ return
+ was_paused = self._last_paused
+ self._last_paused = paused
+ if paused:
+ self._set_liveness(Text(f"● Paused [{self._current_backend}]", style="yellow"))
+ else:
+ self._set_liveness(Text(f"● Active [{self._current_backend}]", style="green"))
+ # Session just came back — promote "Resuming..." to "Thinking..."
+ if was_paused:
+ self._set_activity(Text("Thinking...", style="cyan"))
+
+ # -- Slash-command autocomplete -------------------------------------------
+
+ def _accept_slash_highlighted(self) -> None:
+ """Tab-accept: insert highlighted command, dismiss menu."""
+ menu = self.query_one("#slash-menu", OptionList)
+ input_w = self.query_one("#chat-input", Input)
+ if menu.highlighted is None:
+ return
+ option = menu.get_option_at_index(menu.highlighted)
+ cmd = option.id
+ menu.display = False
+ self._slash_menu_open = False
+ input_w.value = cmd + " " if cmd != "/done" else "/done"
+ input_w.focus()
+ self.set_timer(0.05, lambda: setattr(input_w, "cursor_position", len(input_w.value)))
+
+ _slash_menu_open: bool = False
+
+ async def on_input_changed(self, event: Input.Changed) -> None:
+ if event.input.id != "chat-input":
+ return
+ menu = self.query_one("#slash-menu", OptionList)
+ val = event.value
+ if not val.startswith("/") or " " in val:
+ menu.display = False
+ self._slash_menu_open = False
+ return
+ # Filter commands matching the typed prefix
+ prefix = val.lower()
+ matches = [(cmd, desc) for cmd, desc in SLASH_COMMANDS if cmd.split()[0].startswith(prefix)]
+ menu.clear_options()
+ for cmd, desc in matches:
+ menu.add_option(Option(f"{cmd} — {desc}", id=cmd.split()[0]))
+ menu.display = bool(matches)
+ self._slash_menu_open = bool(matches)
+ if matches:
+ menu.highlighted = 0
+
+ async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ self._accept_slash_highlighted()
+
+ async def on_key(self, event) -> None:
+ if not self._slash_menu_open:
+ return
+ menu = self.query_one("#slash-menu", OptionList)
+ if event.key == "up":
+ if menu.highlighted is not None and menu.highlighted > 0:
+ menu.highlighted -= 1
+ event.prevent_default()
+ event.stop()
+ elif event.key == "down":
+ if menu.highlighted is not None:
+ menu.highlighted += 1
+ event.prevent_default()
+ event.stop()
+ elif event.key == "tab":
+ self._accept_slash_highlighted()
+ event.prevent_default()
+ event.stop()
+ elif event.key == "escape":
+ menu.display = False
+ self._slash_menu_open = False
+ event.prevent_default()
+ event.stop()
+
+ # -- Phase 2: Chat ------------------------------------------------------
+
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
+ if event.input.id == "workspace-input":
+ # Treat Enter on workspace input as clicking Accept
+ self.query_one("#workspace-picker").display = False
+ raw = event.value.strip()
+ workspace_root = Path(raw) if raw else Path(self._cwd) / "workspace"
+ self._on_backend_chosen(LocalBackendConfig(workspace_root=workspace_root))
+ return
+
+ self.query_one("#slash-menu", OptionList).display = False
+ self._slash_menu_open = False
+
+ message = event.value.strip()
+ if not message:
+ return
+
+ input_w = self.query_one("#chat-input", Input)
+ input_w.value = ""
+
+ # Meta-command: /title
+ if message.startswith("/title "):
+ new_title = message[len("/title ") :].strip()
+ if new_title:
+ self._rename_session(new_title)
+ return
+
+ # Meta-command: /fork [optional title] — pick backend then fork
+ if message == "/fork" or message.startswith("/fork "):
+ self._pending_fork_title = message[len("/fork") :].strip() or None
+ self._pending_backend_action = "fork"
+ self._show_backend_picker()
+ return
+
+ # Meta-command: /switch — interactively switch sandbox backend
+ if message == "/switch":
+ self._pending_backend_action = "switch"
+ self._show_backend_picker()
+ return
+
+ # Exit flow
+ if message.lower() == "/done":
+ self._show_exit_prompt()
+ return
+
+ self._chat_write(Text(f"> {message}", style="bold cyan"))
+ input_w.disabled = True
+ if self._last_paused:
+ self._set_activity(Text("Resuming...", style="cyan"))
+ else:
+ self._set_activity(Text("Thinking...", style="cyan"))
+ self._send_message(message)
+
+ @work
+ async def _rename_session(self, new_title: str) -> None:
+ assert self._manager_handle is not None
+ assert self._current_workflow_id is not None
+ try:
+ await self._manager_handle.signal(
+ SessionManagerWorkflow.rename_session,
+ RenameRequest(workflow_id=self._current_workflow_id, title=new_title),
+ )
+ self._set_session_title(new_title)
+ self._chat_write(Text(f"Session renamed to: {new_title}", style="green"))
+ except Exception as e:
+ self._chat_write(Text(f"Rename failed: {e}", style="bold red"))
+
+ @work
+ async def _fork_session(
+ self,
+ title: str | None,
+ backend: BackendConfig | None = None,
+ ) -> None:
+ input_w = self.query_one("#chat-input", Input)
+
+ assert self._manager_handle is not None
+ assert self._current_workflow_id is not None
+
+ input_w.disabled = True
+ self._set_activity(Text("Forking...", style="cyan"))
+ self._chat_write(Text("\nForking session...", style="yellow"))
+
+ try:
+ new_workflow_id: str = await self._manager_handle.execute_update(
+ SessionManagerWorkflow.fork_session,
+ ForkSessionRequest(
+ source_workflow_id=self._current_workflow_id,
+ title=title,
+ target_backend=backend,
+ ),
+ )
+ except Exception as e:
+ self._chat_write(Text(f"Fork failed: {e}", style="bold red"))
+ self._set_activity(Text("Error", style="red"))
+ input_w.disabled = False
+ input_w.focus()
+ return
+
+ # Switch to the forked session
+ self._current_workflow_id = new_workflow_id
+ if backend is not None:
+ self._current_backend = backend.type
+ self._handle = self._temporal_client.get_workflow_handle(new_workflow_id)
+ self._current_turn_id = 0
+
+ # Resolve the title that was assigned
+ fork_title = title or new_workflow_id[-8:]
+ try:
+ sessions = await self._manager_handle.query(SessionManagerWorkflow.list_sessions)
+ for s in sessions:
+ if s.workflow_id == new_workflow_id:
+ fork_title = s.title
+ break
+ except Exception:
+ pass
+
+ self._set_session_title(fork_title)
+ self._chat_write(Text(f"Forked! Now in: {fork_title} ({new_workflow_id})", style="green"))
+ self._set_liveness(Text(f"● Active [{self._current_backend}]", style="green"))
+ self._set_activity()
+ input_w.disabled = False
+ input_w.focus()
+
+ @work
+ async def _switch_backend(self, backend: BackendConfig) -> None:
+ input_w = self.query_one("#chat-input", Input)
+
+ assert self._manager_handle is not None
+ assert self._current_workflow_id is not None
+
+ input_w.disabled = True
+ self._set_activity(Text("Switching backend...", style="cyan"))
+ self._chat_write(Text(f"\nSwitching to {backend.type}...", style="yellow"))
+
+ try:
+ await self._manager_handle.execute_update(
+ SessionManagerWorkflow.switch_backend,
+ SwitchBackendRequest(
+ source_workflow_id=self._current_workflow_id,
+ target_backend=backend,
+ ),
+ )
+ except Exception as e:
+ self._chat_write(Text(f"Switch failed: {e}", style="bold red"))
+ self._set_activity(Text("Error", style="red"))
+ input_w.disabled = False
+ input_w.focus()
+ return
+
+ # Same workflow, just a different backend for subsequent turns
+ self._current_backend = backend.type
+ self._chat_write(Text(f"Switched to {backend.type}!", style="green"))
+ self._set_liveness(Text(f"● Active [{self._current_backend}]", style="green"))
+ self._set_activity()
+ input_w.disabled = False
+ input_w.focus()
+
+ @work
+ async def _send_message(self, message: str) -> None:
+ """Signal the workflow with the user message then poll get_turn_state
+ until the turn is complete or needs approval. No concurrent timers —
+ this single worker owns the entire interaction loop."""
+ input_w = self.query_one("#chat-input", Input)
+ assert self._handle is not None
+
+ # Signal is fire-and-forget — returns immediately
+ try:
+ await self._handle.signal(self._workflow_cls.send_message, message)
+ except Exception as e:
+ self._chat_write(Text(f"Error sending message: {e}", style="bold red"))
+ self._set_activity(Text("Error — try again", style="red"))
+ input_w.disabled = False
+ input_w.focus()
+ return
+
+ # Poll until the workflow has started and finished this turn.
+ # We track turn_id so we don't mistake a stale "complete" from a
+ # previous turn as the response to this message.
+ while True:
+ await asyncio.sleep(1)
+ try:
+ state: TurnState = await self._handle.query(self._workflow_cls.get_turn_state)
+ except Exception as e:
+ self._set_activity(Text(f"Poll error: {e}", style="red"))
+ continue
+
+ # Render tool calls as they appear / update
+ if state.tool_calls:
+ await self._render_live_tool_calls(state)
+
+ # Wait until the workflow has actually started a new turn
+ if state.turn_id <= self._current_turn_id:
+ self._set_activity(Text("Waiting...", style="dim"))
+ continue
+
+ if state.status == "thinking":
+ self._set_activity(Text("Thinking...", style="cyan"))
+
+ elif state.status == "awaiting_approval":
+ # Don't update _current_turn_id here — the approval
+ # continuation is the same turn, so the turn_id check
+ # must still pass when we resume polling after "yes"/"no".
+ tool_desc = state.approval_request.description if state.approval_request else ""
+ self._chat_write(Text(f"\n[approval needed] {tool_desc}", style="yellow"))
+ self._set_activity(Text("Approval required", style="yellow"))
+ self.query_one("#approval-label", Static).update(Text(tool_desc))
+ input_w.display = False
+ self.query_one("#approval-bar").display = True
+ break
+
+ elif state.status == "complete":
+ self._current_turn_id = state.turn_id
+ if state.response_text:
+ self._chat_write(Markdown(state.response_text))
+ self._set_activity()
+ input_w.disabled = False
+ input_w.focus()
+ break
+
+ # -- Approval flow ------------------------------------------------------
+
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
+ btn = event.button.id
+
+ # Backend picker buttons
+ if btn == "btn-backend-daytona":
+ self.query_one("#backend-picker").display = False
+ self._on_backend_chosen(DaytonaBackendConfig())
+ return
+ if btn == "btn-backend-docker":
+ self.query_one("#backend-picker").display = False
+ self._on_backend_chosen(DockerBackendConfig())
+ return
+ if btn == "btn-backend-e2b":
+ self.query_one("#backend-picker").display = False
+ self._on_backend_chosen(E2BBackendConfig())
+ return
+ if btn == "btn-backend-local":
+ self.query_one("#backend-picker").display = False
+ # Show workspace root picker with default = cwd/workspace
+ default_root = str(Path(self._cwd) / "workspace")
+ ws_input = self.query_one("#workspace-input", Input)
+ ws_input.value = default_root
+ self.query_one("#workspace-picker").display = True
+ ws_input.focus()
+ self._set_liveness("Choose workspace root")
+ return
+
+ # Workspace picker buttons
+ if btn == "btn-workspace-accept":
+ self.query_one("#workspace-picker").display = False
+ raw = self.query_one("#workspace-input", Input).value.strip()
+ workspace_root = Path(raw) if raw else Path(self._cwd) / "workspace"
+ self._on_backend_chosen(LocalBackendConfig(workspace_root=workspace_root))
+ return
+ if btn == "btn-workspace-cancel":
+ self.query_one("#workspace-picker").display = False
+ self._show_backend_picker()
+ return
+
+ # Approval buttons
+ if btn in ("btn-approve", "btn-deny"):
+ approved = btn == "btn-approve"
+ self._chat_write(
+ Text(
+ f" -> {'approved' if approved else 'denied'}",
+ style="green" if approved else "red",
+ )
+ )
+ self.query_one("#approval-bar").display = False
+ self.query_one("#chat-input", Input).display = True
+ self.query_one("#chat-input", Input).disabled = True
+ self._set_activity(Text("Thinking...", style="cyan"))
+ self._send_message("yes" if approved else "no")
+ return
+
+ # Fork buttons (kept for UI compatibility, both trigger the same fork)
+ if btn in ("btn-fork-copy", "btn-fork-share"):
+ self.query_one("#fork-bar").display = False
+ self.query_one("#chat-input", Input).display = True
+ self._fork_session(self._pending_fork_title)
+ self._pending_fork_title = None
+ return
+
+ # Exit buttons
+ if btn == "btn-keep":
+ self._on_exit_choice(keep_alive=True)
+ return
+ if btn == "btn-destroy":
+ self._on_exit_choice(keep_alive=False)
+ return
+
+ # -- Phase 3: Exit prompt -----------------------------------------------
+
+ def _show_exit_prompt(self) -> None:
+ """Show the keep-alive / destroy choice."""
+ self.query_one("#chat-input", Input).display = False
+ self.query_one("#exit-bar").display = True
+ self._set_activity("Choose an exit option")
+
+ @work
+ async def _on_exit_choice(self, keep_alive: bool) -> None:
+ self.query_one("#exit-bar").display = False
+
+ if keep_alive:
+ # Pause the workflow so the sandbox state is persisted.
+ if self._handle is not None:
+ self._set_activity(Text("Saving session...", style="cyan"))
+ try:
+ await self._handle.execute_update(self._workflow_cls.pause)
+ except Exception:
+ pass
+ else:
+ assert self._manager_handle is not None
+ assert self._current_workflow_id is not None
+ try:
+ await self._manager_handle.execute_update(
+ SessionManagerWorkflow.destroy_session,
+ self._current_workflow_id,
+ )
+ except Exception:
+ pass
+
+ self._return_to_session_picker()
+
+ def _return_to_session_picker(self) -> None:
+ """Reset chat state and show the session picker again."""
+ if self._poll_timer is not None:
+ self._poll_timer.stop()
+ self._poll_timer = None
+ self._handle = None
+ self._current_workflow_id = None
+
+ # Hide chat UI
+ self._chat_clear()
+ self.query_one("#chat").display = False
+ self.query_one("#chat-input", Input).display = False
+ self.query_one("#approval-bar").display = False
+ self.query_one("#fork-bar").display = False
+ self.query_one("#exit-bar").display = False
+ self.query_one("#snapshot-picker").display = False
+ self.query_one("#backend-picker").display = False
+ self.query_one("#workspace-picker").display = False
+
+ # Re-populate and show the session picker
+ self.sub_title = "Temporal Workflow"
+ self._refresh_session_picker()
+
+ @work
+ async def _refresh_session_picker(self) -> None:
+ """Re-query sessions and show the picker tree."""
+ assert self._manager_handle is not None
+ tree = self.query_one("#session-picker", Tree)
+ sessions = await self._manager_handle.query(SessionManagerWorkflow.list_sessions)
+ await self._backfill_snapshot_ids(sessions)
+ self._populate_session_tree(tree, sessions)
+ tree.root.expand_all()
+ tree.display = True
+ self._set_liveness("Select a session")
+ self._set_activity()
+ tree.focus()
+
+ # -- Graceful quit (Ctrl+C) ---------------------------------------------
+
+ def action_quit_graceful(self) -> None:
+ if self._handle:
+ # In a session — show the keep-alive / destroy prompt
+ self._show_exit_prompt()
+ else:
+ # At the session picker — exit the TUI
+ self.exit()
diff --git a/examples/sandbox/extensions/temporal/temporal_session_manager.py b/examples/sandbox/extensions/temporal/temporal_session_manager.py
new file mode 100644
index 00000000..ab02f35d
--- /dev/null
+++ b/examples/sandbox/extensions/temporal/temporal_session_manager.py
@@ -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
diff --git a/examples/sandbox/extensions/vercel_runner.py b/examples/sandbox/extensions/vercel_runner.py
new file mode 100644
index 00000000..9d33bf1f
--- /dev/null
+++ b/examples/sandbox/extensions/vercel_runner.py
@@ -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 = "
vercel exposed port ok
\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,
+ )
+ )
diff --git a/examples/sandbox/handoffs.py b/examples/sandbox/handoffs.py
new file mode 100644
index 00000000..e70d4a4b
--- /dev/null
+++ b/examples/sandbox/handoffs.py
@@ -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))
diff --git a/examples/sandbox/healthcare_support/README.md b/examples/sandbox/healthcare_support/README.md
new file mode 100644
index 00000000..f2352dfb
--- /dev/null
+++ b/examples/sandbox/healthcare_support/README.md
@@ -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//`.
+
+## 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.
diff --git a/examples/sandbox/healthcare_support/__init__.py b/examples/sandbox/healthcare_support/__init__.py
new file mode 100644
index 00000000..2d04eb8b
--- /dev/null
+++ b/examples/sandbox/healthcare_support/__init__.py
@@ -0,0 +1 @@
+"""Synthetic healthcare support sandbox example."""
diff --git a/examples/sandbox/healthcare_support/data.py b/examples/sandbox/healthcare_support/data.py
new file mode 100644
index 00000000..02279b21
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data.py
@@ -0,0 +1,197 @@
+from __future__ import annotations
+
+import json
+import os
+import re
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from examples.sandbox.healthcare_support.models import KnowledgeSnippet, ScenarioCase
+
+EXAMPLE_ROOT = Path(__file__).resolve().parent
+SCENARIOS_DIR = EXAMPLE_ROOT / "data" / "scenarios"
+FIXTURES_DIR = EXAMPLE_ROOT / "data" / "fixtures"
+POLICIES_DIR = EXAMPLE_ROOT / "policies"
+ROOT_ENV_PATH = EXAMPLE_ROOT.parents[2] / ".env"
+DEMO_ENV_PATH = EXAMPLE_ROOT / ".env"
+
+
+def load_root_env() -> None:
+ """Load environment defaults from the repository root and this demo folder."""
+ for env_path in (ROOT_ENV_PATH, DEMO_ENV_PATH):
+ if not env_path.exists():
+ continue
+
+ for line in env_path.read_text(encoding="utf-8").splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
+ continue
+ key, value = stripped.split("=", 1)
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if key and key not in os.environ:
+ os.environ[key] = value
+
+
+def normalize_text(value: str) -> str:
+ return " ".join(re.findall(r"[a-z0-9]+", value.lower()))
+
+
+def tokenize(value: str) -> set[str]:
+ return set(re.findall(r"[a-z0-9]+", value.lower()))
+
+
+def normalize_date(value: str | None) -> str:
+ if not value:
+ return ""
+ for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%Y/%m/%d", "%m-%d-%Y"):
+ try:
+ return datetime.strptime(value, fmt).strftime("%Y-%m-%d")
+ except ValueError:
+ continue
+ return "".join(re.findall(r"\d+", value))
+
+
+@dataclass
+class PolicyDocument:
+ document_id: str
+ title: str
+ text: str
+
+
+@dataclass
+class HealthcareSupportDataStore:
+ scenarios: dict[str, ScenarioCase]
+ patient_records: list[dict[str, Any]]
+ eligibility_records: list[dict[str, Any]]
+ referral_records: list[dict[str, Any]]
+ policy_documents: list[PolicyDocument]
+
+ @classmethod
+ def load(cls) -> HealthcareSupportDataStore:
+ scenarios = {
+ path.stem: ScenarioCase.model_validate(json.loads(path.read_text(encoding="utf-8")))
+ for path in sorted(SCENARIOS_DIR.glob("*.json"))
+ }
+ patient_records = json.loads(
+ (FIXTURES_DIR / "patient_profiles.json").read_text(encoding="utf-8")
+ )["records"]
+ eligibility_records = json.loads(
+ (FIXTURES_DIR / "insurance_eligibility.json").read_text(encoding="utf-8")
+ )["records"]
+ referral_records = json.loads(
+ (FIXTURES_DIR / "referral_status.json").read_text(encoding="utf-8")
+ )["records"]
+ policy_documents = [
+ PolicyDocument(
+ document_id=path.stem,
+ title=path.stem.replace("_", " ").title(),
+ text=path.read_text(encoding="utf-8"),
+ )
+ for path in sorted(POLICIES_DIR.glob("*.md"))
+ ]
+ return cls(
+ scenarios=scenarios,
+ patient_records=patient_records,
+ eligibility_records=eligibility_records,
+ referral_records=referral_records,
+ policy_documents=policy_documents,
+ )
+
+ def list_scenario_ids(self) -> list[str]:
+ return sorted(self.scenarios)
+
+ def get_scenario(self, scenario_id: str) -> ScenarioCase:
+ try:
+ return self.scenarios[scenario_id]
+ except KeyError as exc:
+ raise KeyError(f"Unknown scenario_id: {scenario_id}") from exc
+
+ def search_policies(self, query: str, top_k: int = 4) -> list[KnowledgeSnippet]:
+ query_terms = tokenize(query)
+ if not query_terms:
+ return []
+
+ scored: list[KnowledgeSnippet] = []
+ for document in self.policy_documents:
+ matched_terms = sorted(query_terms & tokenize(document.text))
+ if not matched_terms:
+ continue
+ score = round(len(matched_terms) / max(len(query_terms), 1), 4)
+ snippet = " ".join(document.text.split())[:320]
+ scored.append(
+ KnowledgeSnippet(
+ document_id=document.document_id,
+ title=document.title,
+ chunk_id=f"{document.document_id}:0",
+ score=score,
+ snippet=snippet,
+ matched_terms=matched_terms,
+ )
+ )
+
+ scored.sort(key=lambda item: item.score, reverse=True)
+ return scored[:top_k]
+
+ def lookup_patient(
+ self,
+ *,
+ patient_id: str | None = None,
+ phone: str | None = None,
+ name: str | None = None,
+ ) -> dict[str, Any]:
+ for record in self.patient_records:
+ if patient_id and record.get("patient_id") == patient_id:
+ return {"lookup_status": "matched", "record": record}
+ if phone and record.get("phone") == phone:
+ return {"lookup_status": "matched", "record": record}
+ if name and normalize_text(record.get("name", "")) == normalize_text(name):
+ return {"lookup_status": "matched", "record": record}
+ return {"lookup_status": "not_found", "record": None}
+
+ def lookup_eligibility(
+ self,
+ *,
+ payer: str | None = None,
+ member_id: str | None = None,
+ dob: str | None = None,
+ ) -> dict[str, Any]:
+ payer_norm = normalize_text(payer or "")
+ dob_norm = normalize_date(dob)
+ fallback_match: dict[str, Any] | None = None
+
+ for record in self.eligibility_records:
+ if member_id and record.get("member_id") != member_id:
+ continue
+ if dob_norm and normalize_date(record.get("dob")) != dob_norm:
+ continue
+ if payer_norm:
+ if normalize_text(record.get("payer", "")) == payer_norm:
+ return {"lookup_status": "matched", **record}
+ continue
+ if fallback_match is None:
+ fallback_match = {"lookup_status": "matched", **record}
+
+ if fallback_match is not None:
+ return fallback_match
+
+ return {
+ "lookup_status": "not_found",
+ "eligibility_status": "unknown",
+ "notes": "No eligibility match. Ask for payer, member ID, and date of birth.",
+ }
+
+ def lookup_referral(
+ self,
+ *,
+ referral_id: str | None = None,
+ patient_id: str | None = None,
+ ) -> dict[str, Any]:
+ for record in self.referral_records:
+ if referral_id and record.get("referral_id") == referral_id:
+ return {"lookup_status": "matched", **record}
+ if patient_id and record.get("patient_id") == patient_id:
+ return {"lookup_status": "matched", **record}
+ return {"lookup_status": "not_found", "status": "unknown"}
diff --git a/examples/sandbox/healthcare_support/data/fixtures/insurance_eligibility.json b/examples/sandbox/healthcare_support/data/fixtures/insurance_eligibility.json
new file mode 100644
index 00000000..e027b226
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/fixtures/insurance_eligibility.json
@@ -0,0 +1,99 @@
+{
+ "records": [
+ {
+ "payer": "Blue Cross",
+ "member_id": "BCX-4439201",
+ "dob": "1985-02-14",
+ "plan_name": "Blue Cross PPO Silver 4500",
+ "eligibility_status": "active",
+ "copay_primary_care": "$35",
+ "copay_specialist": "$60",
+ "deductible_remaining": "$1,200",
+ "prior_auth_required_services": [
+ "mri",
+ "ct angiogram",
+ "elective surgery"
+ ],
+ "notes": "Coverage active. MRI requires prior authorization except emergency use."
+ },
+ {
+ "payer": "UnitedHealthcare",
+ "member_id": "UHC-771032",
+ "dob": "1990-09-03",
+ "plan_name": "UHC Choice Plus Bronze",
+ "eligibility_status": "active",
+ "copay_primary_care": "$30",
+ "copay_specialist": "$75",
+ "deductible_remaining": "$2,050",
+ "prior_auth_required_services": [
+ "ct angiogram",
+ "inpatient admission",
+ "outpatient surgery"
+ ],
+ "notes": "Prior auth required for CT angiogram unless ordered in emergency setting."
+ },
+ {
+ "payer": "Aetna",
+ "member_id": "AET-562100",
+ "dob": "1978-11-20",
+ "plan_name": "Aetna Open Access Basic",
+ "eligibility_status": "active",
+ "copay_primary_care": "$25",
+ "copay_specialist": "$50",
+ "deductible_remaining": "$850",
+ "prior_auth_required_services": [
+ "specialist consult"
+ ],
+ "notes": "Referral on file for specialist consult."
+ },
+ {
+ "payer": "Cigna",
+ "member_id": "CG-291001",
+ "dob": "1982-06-30",
+ "plan_name": "Cigna Connect Gold",
+ "eligibility_status": "active",
+ "copay_primary_care": "$20",
+ "copay_specialist": "$45",
+ "deductible_remaining": "$300",
+ "prior_auth_required_services": [
+ "advanced imaging",
+ "elective procedures"
+ ],
+ "notes": "Claims for advanced imaging can deny if authorization is missing."
+ },
+ {
+ "payer": "Blue Cross",
+ "member_id": "BCX-8822009",
+ "dob": "1974-05-12",
+ "plan_name": "Blue Cross PPO Platinum",
+ "eligibility_status": "active",
+ "copay_primary_care": "$20",
+ "copay_specialist": "$40",
+ "deductible_remaining": "$0",
+ "prior_auth_required_services": [
+ "physical therapy after 12 visits"
+ ],
+ "notes": "Physical therapy benefit allows 12 visits without prior authorization per calendar year."
+ },
+ {
+ "payer": "Blue Cross",
+ "member_id": "BCX-9017710",
+ "dob": "1992-04-17",
+ "plan_name": "Blue Cross PPO Silver 3000",
+ "eligibility_status": "active",
+ "copay_primary_care": "$30",
+ "copay_specialist": "$55",
+ "deductible_remaining": "$1,600",
+ "prior_auth_required_services": [
+ "mri",
+ "knee surgery consult",
+ "outpatient surgery"
+ ],
+ "notes": "Prior auth normally required for knee surgery consult and advanced imaging."
+ }
+ ],
+ "default_response": {
+ "eligibility_status": "unknown",
+ "notes": "No eligibility match. Confirm payer, member ID, and DOB."
+ }
+}
diff --git a/examples/sandbox/healthcare_support/data/fixtures/patient_profiles.json b/examples/sandbox/healthcare_support/data/fixtures/patient_profiles.json
new file mode 100644
index 00000000..3cf3cacb
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/fixtures/patient_profiles.json
@@ -0,0 +1,58 @@
+{
+ "records": [
+ {
+ "patient_id": "PAT-1001",
+ "name": "Maya Thompson",
+ "dob": "1985-02-14",
+ "phone": "555-0111",
+ "payer": "Blue Cross",
+ "member_id": "BCX-4439201",
+ "referral_id": "REF-44120"
+ },
+ {
+ "patient_id": "PAT-1002",
+ "name": "Victor Chen",
+ "dob": "1990-09-03",
+ "phone": "555-0122",
+ "payer": "UnitedHealthcare",
+ "member_id": "UHC-771032",
+ "referral_id": "REF-77100"
+ },
+ {
+ "patient_id": "PAT-1003",
+ "name": "Nora Patel",
+ "dob": "1978-11-20",
+ "phone": "555-0133",
+ "payer": "Aetna",
+ "member_id": "AET-562100",
+ "referral_id": "REF-88421"
+ },
+ {
+ "patient_id": "PAT-1004",
+ "name": "Luis Romero",
+ "dob": "1982-06-30",
+ "phone": "555-0144",
+ "payer": "Cigna",
+ "member_id": "CG-291001",
+ "referral_id": "REF-12880"
+ },
+ {
+ "patient_id": "PAT-1005",
+ "name": "Ella Brooks",
+ "dob": "1974-05-12",
+ "phone": "555-0155",
+ "payer": "Blue Cross",
+ "member_id": "BCX-8822009",
+ "referral_id": "REF-33002"
+ },
+ {
+ "patient_id": "PAT-1006",
+ "name": "Jordan Lee",
+ "dob": "1992-04-17",
+ "phone": "555-0134",
+ "payer": "Blue Cross",
+ "member_id": "BCX-9017710",
+ "referral_id": "REF-90171"
+ }
+ ]
+}
diff --git a/examples/sandbox/healthcare_support/data/fixtures/referral_status.json b/examples/sandbox/healthcare_support/data/fixtures/referral_status.json
new file mode 100644
index 00000000..f7dbaa23
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/fixtures/referral_status.json
@@ -0,0 +1,34 @@
+{
+ "records": [
+ {
+ "referral_id": "REF-88421",
+ "patient_id": "PAT-1003",
+ "status": "approved",
+ "specialty": "Cardiology",
+ "requested_provider": "Dr. Ramos",
+ "authorized_visits": 6,
+ "remaining_visits": 4,
+ "notes": "Authorization valid through 2026-07-31."
+ },
+ {
+ "referral_id": "REF-77100",
+ "patient_id": "PAT-1002",
+ "status": "pending_clinical_review",
+ "specialty": "Radiology",
+ "requested_provider": "Riverfront Imaging",
+ "authorized_visits": 1,
+ "remaining_visits": 0,
+ "notes": "Pending prior authorization packet completion."
+ },
+ {
+ "referral_id": "REF-90171",
+ "patient_id": "PAT-1006",
+ "status": "pending",
+ "specialty": "Orthopedics",
+ "requested_provider": "Summit Ortho Group",
+ "authorized_visits": 8,
+ "remaining_visits": 8,
+ "notes": "Awaiting payer determination."
+ }
+ ]
+}
diff --git a/examples/sandbox/healthcare_support/data/scenarios/billing_coverage_clarification.json b/examples/sandbox/healthcare_support/data/scenarios/billing_coverage_clarification.json
new file mode 100644
index 00000000..659d48bd
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/scenarios/billing_coverage_clarification.json
@@ -0,0 +1,30 @@
+{
+ "scenario_id": "billing_coverage_clarification",
+ "description": "Patient received an unexpected imaging bill and wants coverage clarification.",
+ "transcript": "Hey, this is Luis Romero. I got a bill after an ultrasound on 2026-02-08 and I thought it was covered.\nMy insurance is Cigna and my member ID is CG-291001.\nCan someone explain what happened and what I should do now?",
+ "patient_metadata": {
+ "patient_id": "PAT-1004"
+ },
+ "followup_qa": {
+ "date of service": "2026-02-08",
+ "payer": "Cigna"
+ },
+ "expected": {
+ "intent": "billing_coverage_clarification",
+ "required_entities": {
+ "payer": "Cigna",
+ "member_id": "CG-291001"
+ },
+ "required_tool_calls": [
+ "insurance_eligibility_lookup"
+ ],
+ "required_resolution_elements": [
+ "billing coverage review",
+ "recommended next step"
+ ],
+ "expected_payer": "Cigna"
+ },
+ "gold": {
+ "expected_next_step": "Route to billing review with EOB and service date context."
+ }
+}
diff --git a/examples/sandbox/healthcare_support/data/scenarios/blue_cross_pt_benefits.json b/examples/sandbox/healthcare_support/data/scenarios/blue_cross_pt_benefits.json
new file mode 100644
index 00000000..39562a61
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/scenarios/blue_cross_pt_benefits.json
@@ -0,0 +1,30 @@
+{
+ "scenario_id": "blue_cross_pt_benefits",
+ "description": "Blue Cross member asks about remaining physical therapy benefit and coverage path.",
+ "transcript": "This is Ella Brooks. I am a Blue Cross member and my ID is BCX-8822009.\nI am trying to continue physical therapy and need to know if I still have covered visits left.\nI do not have my date of birth in front of me if you need it.",
+ "patient_metadata": {
+ "patient_id": "PAT-1005"
+ },
+ "followup_qa": {
+ "date of birth": "05/12/1974",
+ "physical therapy": "physical therapy"
+ },
+ "expected": {
+ "intent": "eligibility_verification",
+ "required_entities": {
+ "payer": "Blue Cross",
+ "member_id": "BCX-8822009"
+ },
+ "required_tool_calls": [
+ "insurance_eligibility_lookup"
+ ],
+ "required_resolution_elements": [
+ "eligibility verified",
+ "recommended next step"
+ ],
+ "expected_payer": "Blue Cross"
+ },
+ "gold": {
+ "expected_next_step": "Confirm PT visit limits and advise on when additional review is needed."
+ }
+}
diff --git a/examples/sandbox/healthcare_support/data/scenarios/eligibility_verification_basic.json b/examples/sandbox/healthcare_support/data/scenarios/eligibility_verification_basic.json
new file mode 100644
index 00000000..be0eda3a
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/scenarios/eligibility_verification_basic.json
@@ -0,0 +1,30 @@
+{
+ "scenario_id": "eligibility_verification_basic",
+ "description": "Basic eligibility verification call with clear Blue Cross identifiers.",
+ "transcript": "Hi, this is Maya Thompson. I have an MRI next week and I want to confirm if it is covered.\nI have Blue Cross and my member ID is BCX-4439201. My date of birth is 02/14/1985.\nCan you tell me what my benefits look like and what I should do next?",
+ "patient_metadata": {
+ "patient_id": "PAT-1001"
+ },
+ "followup_qa": {
+ "member ID": "BCX-4439201",
+ "date of birth": "02/14/1985"
+ },
+ "expected": {
+ "intent": "eligibility_verification",
+ "required_entities": {
+ "payer": "Blue Cross",
+ "member_id": "BCX-4439201"
+ },
+ "required_tool_calls": [
+ "insurance_eligibility_lookup"
+ ],
+ "required_resolution_elements": [
+ "eligibility verified",
+ "recommended next step"
+ ],
+ "expected_payer": "Blue Cross"
+ },
+ "gold": {
+ "expected_next_step": "Confirm prior auth requirement for MRI and proceed with scheduling."
+ }
+}
diff --git a/examples/sandbox/healthcare_support/data/scenarios/messy_ambiguous_knee_case.json b/examples/sandbox/healthcare_support/data/scenarios/messy_ambiguous_knee_case.json
new file mode 100644
index 00000000..6c85ffd6
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/scenarios/messy_ambiguous_knee_case.json
@@ -0,0 +1,34 @@
+{
+ "scenario_id": "messy_ambiguous_knee_case",
+ "description": "Messy real-world call with ambiguous details requiring follow-up, retrieval, and multiple tool invocations.",
+ "transcript": "Hi, this is Jordan Lee. I had a knee surgery consult and maybe some imaging planned, then I got mixed messages about auth.\nI also saw a bill and I am not sure if this is Blue something PPO or what.\nMy phone is 555-0134 and I think the referral might be REF-90171.\nCan you figure out what I need to do next?",
+ "patient_metadata": {
+ "patient_id": "PAT-1006"
+ },
+ "followup_qa": {
+ "insurance payer": "Blue Cross",
+ "member ID": "BCX-9017710",
+ "date of birth": "04/17/1992",
+ "procedure or visit type": "knee surgery consult",
+ "referral ID": "REF-90171"
+ },
+ "expected": {
+ "intent": "prior_auth_confusion",
+ "required_entities": {
+ "payer": "Blue Cross",
+ "member_id": "BCX-9017710"
+ },
+ "required_tool_calls": [
+ "insurance_eligibility_lookup",
+ "appointment_referral_status_lookup"
+ ],
+ "required_resolution_elements": [
+ "prior authorization",
+ "recommended next step"
+ ],
+ "expected_payer": "Blue Cross"
+ },
+ "gold": {
+ "expected_next_step": "Route to auth queue and share referral pending status with patient."
+ }
+}
diff --git a/examples/sandbox/healthcare_support/data/scenarios/prior_auth_confusion_ct.json b/examples/sandbox/healthcare_support/data/scenarios/prior_auth_confusion_ct.json
new file mode 100644
index 00000000..317740e5
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/scenarios/prior_auth_confusion_ct.json
@@ -0,0 +1,32 @@
+{
+ "scenario_id": "prior_auth_confusion_ct",
+ "description": "Caller is confused about whether CT angiogram needs prior auth and what intake should do.",
+ "transcript": "This is Victor Chen. I was told to schedule a CT angiogram, but another office said prior authorization is missing.\nMy insurance is UnitedHealthcare and I think my ID is UHC-771032.\nI need to know if I can move forward or if you need more information.",
+ "patient_metadata": {
+ "patient_id": "PAT-1002"
+ },
+ "followup_qa": {
+ "date of birth": "09/03/1990",
+ "procedure or visit type": "CT angiogram",
+ "payer": "UnitedHealthcare",
+ "member ID": "UHC-771032"
+ },
+ "expected": {
+ "intent": "prior_auth_confusion",
+ "required_entities": {
+ "payer": "UnitedHealthcare",
+ "member_id": "UHC-771032"
+ },
+ "required_tool_calls": [
+ "insurance_eligibility_lookup"
+ ],
+ "required_resolution_elements": [
+ "prior authorization",
+ "recommended next step"
+ ],
+ "expected_payer": "UnitedHealthcare"
+ },
+ "gold": {
+ "expected_next_step": "Route to utilization review with CT angiogram authorization packet."
+ }
+}
diff --git a/examples/sandbox/healthcare_support/data/scenarios/referral_status_check.json b/examples/sandbox/healthcare_support/data/scenarios/referral_status_check.json
new file mode 100644
index 00000000..715641bd
--- /dev/null
+++ b/examples/sandbox/healthcare_support/data/scenarios/referral_status_check.json
@@ -0,0 +1,29 @@
+{
+ "scenario_id": "referral_status_check",
+ "description": "Patient asks for specialist referral status with known referral ID.",
+ "transcript": "Hi, this is Nora Patel. I am checking on referral number REF-88421 for cardiology with Dr. Ramos.\nCan you tell me if it has been approved and how many visits I still have?",
+ "patient_metadata": {
+ "patient_id": "PAT-1003"
+ },
+ "followup_qa": {
+ "referral number": "REF-88421",
+ "provider": "Dr. Ramos"
+ },
+ "expected": {
+ "intent": "referral_status_question",
+ "required_entities": {
+ "referral_id": "REF-88421"
+ },
+ "required_tool_calls": [
+ "appointment_referral_status_lookup"
+ ],
+ "required_resolution_elements": [
+ "referral",
+ "remaining authorized visits"
+ ],
+ "expected_payer": "Aetna"
+ },
+ "gold": {
+ "expected_next_step": "Notify patient referral is approved and proceed to specialist scheduling."
+ }
+}
diff --git a/examples/sandbox/healthcare_support/main.py b/examples/sandbox/healthcare_support/main.py
new file mode 100644
index 00000000..53ffc36b
--- /dev/null
+++ b/examples/sandbox/healthcare_support/main.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+if __package__ is None or __package__ == "":
+ _DEMO_DIR = Path(__file__).resolve().parent
+ sys.path.insert(0, str(_DEMO_DIR.parents[2]))
+ sys.path.insert(0, str(_DEMO_DIR))
+
+from examples.auto_mode import confirm_with_fallback, input_with_fallback # noqa: E402
+from examples.sandbox.healthcare_support.data import ( # noqa: E402
+ HealthcareSupportDataStore,
+ load_root_env,
+)
+from examples.sandbox.healthcare_support.models import ScenarioCase # noqa: E402
+from examples.sandbox.healthcare_support.tools import HealthcareSupportContext # noqa: E402
+from examples.sandbox.healthcare_support.workflow import ( # noqa: E402
+ CACHE_ROOT,
+ DEFAULT_SESSION_ID,
+ SESSION_DB_PATH,
+ build_context,
+ run_healthcare_support_workflow,
+)
+
+DEFAULT_SCENARIO_ID = "eligibility_verification_basic"
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Run the healthcare support Agents SDK demo from the command line.",
+ )
+ parser.add_argument(
+ "--scenario",
+ dest="scenario_id",
+ default=None,
+ help="Scenario ID to run. If omitted, the CLI asks interactively.",
+ )
+ parser.add_argument(
+ "--list-scenarios",
+ action="store_true",
+ help="Print the built-in scenario IDs and exit.",
+ )
+ parser.add_argument(
+ "--reset-memory",
+ action="store_true",
+ help="Delete the shared SQLite session database before running.",
+ )
+ return parser
+
+
+def _print_scenarios(store: HealthcareSupportDataStore) -> None:
+ print("Available scenarios:\n")
+ for scenario_id in store.list_scenario_ids():
+ scenario = store.get_scenario(scenario_id)
+ print(f"- {scenario.scenario_id}")
+ print(f" {scenario.description}")
+
+
+def _pick_scenario(store: HealthcareSupportDataStore, requested_id: str | None) -> ScenarioCase:
+ if requested_id:
+ return store.get_scenario(requested_id)
+
+ scenario_id = input_with_fallback(
+ "Enter a scenario ID: ",
+ DEFAULT_SCENARIO_ID,
+ ).strip()
+ if not scenario_id:
+ scenario_id = DEFAULT_SCENARIO_ID
+ return store.get_scenario(scenario_id)
+
+
+async def _approval_handler(request: dict[str, Any]) -> bool:
+ print("\nHuman approval requested")
+ print(f"Agent: {request.get('agent', 'unknown')}")
+ print(f"Tool: {request.get('tool', 'route_to_human_queue')}")
+ print(json.dumps(request.get("arguments", {}), indent=2))
+ return confirm_with_fallback("Approve handoff to a human queue? [y/N]: ", True)
+
+
+def _print_run_header(*, scenario: ScenarioCase, context: HealthcareSupportContext) -> None:
+ print("\n" + "=" * 80)
+ print("Healthcare Support Agents SDK Demo")
+ print(f"Scenario: {scenario.scenario_id}")
+ print(f"Description: {scenario.description}")
+ print(f"SQLite memory session: {context.session_id}")
+ print("\nCustomer transcript:\n")
+ print(scenario.transcript)
+
+
+def _print_run_result(payload: dict[str, Any]) -> None:
+ print("\nTrace URL:")
+ print(payload["trace_url"])
+
+ print("\nPatient-facing response:\n")
+ print(payload["resolution"]["patient_facing_response"])
+
+ print("\nInternal summary:")
+ print(payload["resolution"]["internal_summary"])
+
+ print("\nNext step:")
+ print(payload["resolution"]["next_step"])
+
+ if payload["resolution"].get("handoff_id"):
+ print("\nHuman handoff:")
+ print(payload["resolution"]["handoff_id"])
+
+ print("\nGenerated sandbox artifacts:")
+ for artifact in payload.get("artifacts", []):
+ print(f"- {artifact['path']}")
+
+ print("\nMemory recap:")
+ print(json.dumps(payload["memory_recap"], indent=2))
+
+ print(f"\nSession memory items: {payload['session_memory_items']}")
+
+
+async def main() -> None:
+ load_root_env()
+ args = _build_parser().parse_args()
+ store = HealthcareSupportDataStore.load()
+
+ if args.list_scenarios:
+ _print_scenarios(store)
+ return
+
+ if args.reset_memory and SESSION_DB_PATH.exists():
+ SESSION_DB_PATH.unlink()
+
+ scenario = _pick_scenario(store, args.scenario_id)
+ context = build_context(
+ store=store,
+ scenario_id=scenario.scenario_id,
+ session_id=DEFAULT_SESSION_ID,
+ )
+ CACHE_ROOT.mkdir(parents=True, exist_ok=True)
+
+ _print_run_header(scenario=scenario, context=context)
+ payload = await run_healthcare_support_workflow(
+ context=context,
+ scenario_id=scenario.scenario_id,
+ approval_handler=_approval_handler,
+ )
+ _print_run_result(payload)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/sandbox/healthcare_support/models.py b/examples/sandbox/healthcare_support/models.py
new file mode 100644
index 00000000..248429f6
--- /dev/null
+++ b/examples/sandbox/healthcare_support/models.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+IntentName = Literal[
+ "eligibility_verification",
+ "prior_auth_confusion",
+ "referral_status_question",
+ "billing_coverage_clarification",
+ "general_intake",
+]
+
+
+class ScenarioExpectation(BaseModel):
+ intent: IntentName
+ required_entities: dict[str, str] = Field(default_factory=dict)
+ required_tool_calls: list[str] = Field(default_factory=list)
+ required_resolution_elements: list[str] = Field(default_factory=list)
+ expected_payer: str | None = None
+
+
+class ScenarioCase(BaseModel):
+ scenario_id: str
+ description: str
+ transcript: str
+ patient_metadata: dict[str, Any] = Field(default_factory=dict)
+ followup_qa: dict[str, str] = Field(default_factory=dict)
+ expected: ScenarioExpectation
+ gold: dict[str, Any] = Field(default_factory=dict)
+
+
+class KnowledgeSnippet(BaseModel):
+ document_id: str
+ title: str
+ chunk_id: str
+ score: float
+ snippet: str
+ matched_terms: list[str] = Field(default_factory=list)
+
+
+class BenefitReview(BaseModel):
+ patient_name: str
+ patient_id: str
+ payer: str
+ member_id: str
+ eligibility_status: str
+ plan_summary: str
+ referral_status: str
+ prior_auth_recommended: bool
+ recommended_queue: str
+ summary: str
+
+
+class SandboxPolicyPacket(BaseModel):
+ matched_policy_files: list[str] = Field(default_factory=list)
+ generated_files: list[str] = Field(default_factory=list)
+ shell_commands: list[str] = Field(default_factory=list)
+ policy_summary: str
+ human_review_recommended: bool
+
+
+class CaseResolution(BaseModel):
+ scenario_id: str
+ intent: IntentName
+ patient_name: str
+ benefits_summary: str
+ policy_summary: str
+ next_step: str
+ route_to_human: bool
+ handoff_id: str | None = None
+ generated_files: list[str] = Field(default_factory=list)
+ internal_summary: str
+ patient_facing_response: str
+
+
+class MemoryRecap(BaseModel):
+ remembered_patient: str | None = None
+ remembered_intent: IntentName | None = None
+ remembered_next_step: str
+ remembered_handoff: str | None = None
+ remembered_files: list[str] = Field(default_factory=list)
diff --git a/examples/sandbox/healthcare_support/policies/auth_review_queue_routing.md b/examples/sandbox/healthcare_support/policies/auth_review_queue_routing.md
new file mode 100644
index 00000000..f88f3369
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/auth_review_queue_routing.md
@@ -0,0 +1,8 @@
+# Auth Review Queue Routing
+
+- Route to auth-review-queue when prior authorization is required, likely required, or blocked by
+ missing CPT/diagnosis details.
+- Route to care-team-intake-queue when referral or scheduling data is incomplete but payer auth is
+ not yet indicated.
+- Route to billing-review-queue only for claim denial, refund, or balance disputes.
+- High-priority auth review applies when surgery or advanced imaging is expected within 14 days.
diff --git a/examples/sandbox/healthcare_support/policies/billing_after_consult_faq.md b/examples/sandbox/healthcare_support/policies/billing_after_consult_faq.md
new file mode 100644
index 00000000..c828ce70
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/billing_after_consult_faq.md
@@ -0,0 +1,7 @@
+# Billing After Consult FAQ
+
+- A consult bill can be generated before imaging or surgery authorization is complete.
+- Patients often confuse referral approval, prior authorization, and claim adjudication.
+- Staff should explain that consult billing does not confirm surgery authorization.
+- If the patient reports a bill plus auth confusion, verify eligibility and route to billing only
+ when the question is about claim denial or patient balance.
diff --git a/examples/sandbox/healthcare_support/policies/blue_cross_benefits_reference.md b/examples/sandbox/healthcare_support/policies/blue_cross_benefits_reference.md
new file mode 100644
index 00000000..c21a3985
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/blue_cross_benefits_reference.md
@@ -0,0 +1,6 @@
+# Blue Cross Benefits Reference
+
+- Common PPO orthopedic specialist copays range from $40 to $75 depending on employer group.
+- Deductible and coinsurance still apply to imaging and outpatient surgery.
+- Benefit verification should capture specialist copay, deductible remaining, and coinsurance.
+- Benefits data should be summarized separately from authorization status.
diff --git a/examples/sandbox/healthcare_support/policies/blue_cross_ppo_prior_auth.md b/examples/sandbox/healthcare_support/policies/blue_cross_ppo_prior_auth.md
new file mode 100644
index 00000000..23ccc3d3
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/blue_cross_ppo_prior_auth.md
@@ -0,0 +1,9 @@
+# Blue Cross PPO Prior Authorization
+
+- PPO members require prior authorization for inpatient surgery, outpatient surgery over $1,500,
+ and advanced imaging tied to surgical planning.
+- Knee surgery consults do not require prior authorization by themselves.
+- MRI or CT imaging ordered after the consult may require prior authorization if performed at a
+ hospital outpatient department.
+- If referral status is pending, route to auth review before scheduling imaging.
+- Required fields: member ID, date of birth, ordering provider, CPT code, diagnosis code.
diff --git a/examples/sandbox/healthcare_support/policies/blue_cross_referral_rules.md b/examples/sandbox/healthcare_support/policies/blue_cross_referral_rules.md
new file mode 100644
index 00000000..9c7dfd3e
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/blue_cross_referral_rules.md
@@ -0,0 +1,8 @@
+# Blue Cross Referral Rules
+
+- PPO plans do not usually require a PCP referral for orthopedic consults.
+- Some employer groups still require a referral number for specialist scheduling.
+- If a referral exists but is pending, staff should verify status before confirming downstream
+ imaging or surgery appointments.
+- Pending referrals should be routed to the care-team intake queue or auth-review queue depending
+ on whether authorization is also required.
diff --git a/examples/sandbox/healthcare_support/policies/commercial_eligibility_checklist.md b/examples/sandbox/healthcare_support/policies/commercial_eligibility_checklist.md
new file mode 100644
index 00000000..1eca8ab9
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/commercial_eligibility_checklist.md
@@ -0,0 +1,6 @@
+# Commercial Eligibility Checklist
+
+- Verify payer name, member ID, date of birth, and plan status.
+- Confirm effective date, termination date, copay, deductible, and coinsurance.
+- If payer name is ambiguous, use member ID and DOB to identify the most likely eligibility match.
+- Eligibility verification does not replace prior authorization review.
diff --git a/examples/sandbox/healthcare_support/policies/human_escalation_policy.md b/examples/sandbox/healthcare_support/policies/human_escalation_policy.md
new file mode 100644
index 00000000..fcf2e895
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/human_escalation_policy.md
@@ -0,0 +1,7 @@
+# Human Escalation Policy
+
+- Escalate to a human when payer is ambiguous, prior authorization is likely, referral is pending,
+ or procedure coding is incomplete.
+- Escalate when patient asks for next steps and multiple operational dependencies are unresolved.
+- Human queue payloads should include patient summary, payer, member ID, referral ID, requested
+ service, and missing information.
diff --git a/examples/sandbox/healthcare_support/policies/knee_surgery_medical_necessity.md b/examples/sandbox/healthcare_support/policies/knee_surgery_medical_necessity.md
new file mode 100644
index 00000000..40b72752
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/knee_surgery_medical_necessity.md
@@ -0,0 +1,7 @@
+# Knee Surgery Medical Necessity
+
+- Surgical review packets should include consult notes, imaging results, diagnosis, failed
+ conservative treatment, and requested CPT code.
+- Missing imaging results are a common reason for delayed authorization.
+- If the patient has a consult but no final procedure code, route to human review for packet
+ completion before payer submission.
diff --git a/examples/sandbox/healthcare_support/policies/orthopedic_imaging_policy.md b/examples/sandbox/healthcare_support/policies/orthopedic_imaging_policy.md
new file mode 100644
index 00000000..dab23312
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/orthopedic_imaging_policy.md
@@ -0,0 +1,7 @@
+# Orthopedic Imaging Policy
+
+- X-ray does not require prior authorization for most commercial plans.
+- MRI of knee without contrast often requires prior authorization when ordered before surgery.
+- CT lower extremity may require prior authorization when tied to operative planning.
+- Imaging requests should include laterality, diagnosis code, and conservative treatment history
+ when available.
diff --git a/examples/sandbox/healthcare_support/policies/outbound_fax_packet_requirements.md b/examples/sandbox/healthcare_support/policies/outbound_fax_packet_requirements.md
new file mode 100644
index 00000000..36bcdee8
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/outbound_fax_packet_requirements.md
@@ -0,0 +1,7 @@
+# Outbound Fax Packet Requirements
+
+- Prior auth packets should include cover sheet, demographics, insurance card data, consult notes,
+ imaging reports, and requested CPT/ICD-10 codes.
+- If any required artifact is missing, create a missing-items checklist before faxing.
+- Human review is required before outbound fax when packet data is incomplete or referral status is
+ pending.
diff --git a/examples/sandbox/healthcare_support/policies/patient_messaging_guidelines.md b/examples/sandbox/healthcare_support/policies/patient_messaging_guidelines.md
new file mode 100644
index 00000000..74f3fbe9
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/patient_messaging_guidelines.md
@@ -0,0 +1,7 @@
+# Patient Messaging Guidelines
+
+- Use plain language and separate what is verified from what is still under review.
+- Do not tell a patient that surgery is approved unless payer authorization is confirmed.
+- If referral is pending, say that the referral is still being reviewed and that the care team is
+ checking whether payer authorization is also needed.
+- Provide one clear next step and one expected owner queue.
diff --git a/examples/sandbox/healthcare_support/policies/referral_pending_sop.md b/examples/sandbox/healthcare_support/policies/referral_pending_sop.md
new file mode 100644
index 00000000..d65a5add
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/referral_pending_sop.md
@@ -0,0 +1,7 @@
+# Referral Pending SOP
+
+- Confirm referral ID, patient identity, and rendering specialist before escalation.
+- If referral status is pending for more than two business days, send to care-team intake queue.
+- If referral is pending and prior authorization is also likely, send to auth-review queue with a
+ note that referral clearance is still outstanding.
+- Patient messaging should distinguish referral review from payer authorization.
diff --git a/examples/sandbox/healthcare_support/policies/scheduling_hold_policy.md b/examples/sandbox/healthcare_support/policies/scheduling_hold_policy.md
new file mode 100644
index 00000000..cabe3e61
--- /dev/null
+++ b/examples/sandbox/healthcare_support/policies/scheduling_hold_policy.md
@@ -0,0 +1,6 @@
+# Scheduling Hold Policy
+
+- Do not schedule surgery until required payer authorization is approved.
+- Imaging may be tentatively scheduled only when policy allows no-auth outpatient imaging.
+- If referral or authorization is pending, place a scheduling hold and notify the patient of the
+ review owner.
diff --git a/examples/sandbox/healthcare_support/skills/prior-auth-packet-builder/SKILL.md b/examples/sandbox/healthcare_support/skills/prior-auth-packet-builder/SKILL.md
new file mode 100644
index 00000000..ab940361
--- /dev/null
+++ b/examples/sandbox/healthcare_support/skills/prior-auth-packet-builder/SKILL.md
@@ -0,0 +1,32 @@
+---
+name: prior-auth-packet-builder
+description: Build a concise prior authorization packet from local case files and payer policy docs.
+---
+
+# Prior Auth Packet Builder
+
+Use this skill when a case requires prior authorization review, referral validation, imaging review,
+or payer-specific policy checks.
+
+## Workflow
+
+1. Inspect `case/scenario.json` and `case/transcript.txt`.
+2. Use `rg` against `policies/` to find payer, prior auth, referral, imaging, and PPO guidance.
+3. Read only the most relevant policy files.
+4. Create `output/policy_findings.md` with:
+ - case summary
+ - matched policy files
+ - prior auth determination
+ - referral determination
+ - missing information
+5. Create `output/human_review_checklist.md` with:
+ - what a human reviewer should verify
+ - what to tell the patient
+ - what queue should own the case
+
+## Rules
+
+- Use targeted `rg` searches over broad file reads.
+- Only cite policy files you actually inspected.
+- Keep outputs concise and operational.
+- If referral status is pending and prior auth is unclear, recommend human review.
diff --git a/examples/sandbox/healthcare_support/support_agents.py b/examples/sandbox/healthcare_support/support_agents.py
new file mode 100644
index 00000000..55c4b16c
--- /dev/null
+++ b/examples/sandbox/healthcare_support/support_agents.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from openai.types.shared import Reasoning
+
+from agents import Agent, AgentOutputSchema, ModelSettings, Tool
+from agents.sandbox import SandboxAgent
+from agents.sandbox.capabilities import Filesystem, LocalDirLazySkillSource, Shell, Skills
+from agents.sandbox.entries import LocalDir
+from examples.sandbox.healthcare_support.models import (
+ BenefitReview,
+ CaseResolution,
+ MemoryRecap,
+ SandboxPolicyPacket,
+)
+from examples.sandbox.healthcare_support.tools import (
+ HealthcareSupportContext,
+ lookup_insurance_eligibility,
+ lookup_patient,
+ lookup_referral_status,
+ route_to_human_queue,
+)
+
+BENEFITS_PROMPT = """
+You are a healthcare benefits specialist in a synthetic support workflow.
+
+Use the available lookup tools to verify patient, eligibility, and referral details, then return a
+structured benefits review.
+
+Rules:
+1. Call `patient_info_lookup` first when you have a patient ID, phone number, or patient name.
+2. Call `insurance_eligibility_lookup` when payer, member ID, or date of birth is available.
+3. Call `appointment_referral_status_lookup` when referral ID or patient ID is available.
+4. Recommend prior-auth review only when the case involves imaging, surgery, a pending referral, or
+ policy-specific authorization language.
+5. Set `recommended_queue` to one of `care-team-intake-queue`, `auth-review-queue`, or
+ `billing-review-queue`.
+6. Keep the summary concise and grounded in tool output.
+""".strip()
+
+
+POLICY_SANDBOX_PROMPT = """
+You are a policy packet specialist running inside a sandbox workspace.
+
+Inspect the case files and local policy library, generate concise markdown artifacts in `output/`,
+and return a structured packet summary.
+
+You must:
+1. Load and use the `prior-auth-packet-builder` skill.
+2. Inspect the workspace with shell commands before writing anything.
+3. Use `rg` against `policies/` for prior-auth, imaging, referral, billing, PPO, and Blue Cross
+ policy guidance.
+4. Create `output/policy_findings.md` with the most relevant policy guidance.
+5. Create `output/human_review_checklist.md` with a short checklist for a human reviewer.
+6. Set `human_review_recommended=true` only when the policy search or case input shows missing
+ authorization/referral details that should be reviewed by a human before responding.
+7. Include the exact shell commands you ran in `shell_commands`.
+8. Return only facts grounded in the files you inspected.
+""".strip()
+
+
+ORCHESTRATOR_PROMPT = """
+You are a healthcare support orchestrator.
+
+Coordinate a synthetic support case by combining a benefits review, a sandbox policy packet review,
+and a human handoff only when the case genuinely needs it.
+
+Rules:
+1. Always call `benefits_review` first.
+2. Always call `sandbox_policy_packet` second.
+3. For this demo, call `route_to_human_queue` only for the
+ `messy_ambiguous_knee_case` scenario when the sandbox packet recommends human review.
+4. Do not escalate the other four scenarios; answer those directly from the benefits and sandbox
+ outputs.
+5. If you call `route_to_human_queue`, include the returned `handoff_id` and set
+ `route_to_human=true`.
+6. Produce a clear patient-facing response, a short internal summary, and a concrete next step.
+7. Use only facts from the tool outputs and the supplied scenario payload.
+""".strip()
+
+
+MEMORY_PROMPT = """
+Summarize what you remember from this SQLite-backed session about the prior patient support cases.
+
+Include the most recently remembered patient, intent, handoff status, generated files, and next
+step. Do not call tools.
+""".strip()
+
+
+benefits_agent = Agent[HealthcareSupportContext](
+ name="HealthcareBenefitsAgent",
+ model="gpt-5.4",
+ instructions=BENEFITS_PROMPT,
+ model_settings=ModelSettings(reasoning=Reasoning(effort="low"), verbosity="low"),
+ tools=[
+ lookup_patient,
+ lookup_insurance_eligibility,
+ lookup_referral_status,
+ ],
+ output_type=AgentOutputSchema(BenefitReview, strict_json_schema=False),
+)
+
+
+def build_policy_sandbox_agent(*, skills_root: Path) -> SandboxAgent[HealthcareSupportContext]:
+ return SandboxAgent[HealthcareSupportContext](
+ name="HealthcarePolicySandboxAgent",
+ model="gpt-5.4",
+ instructions=(
+ POLICY_SANDBOX_PROMPT + "\n\n"
+ "Use `load_skill` before reading the skill file. Use `exec_command` with `pwd`, "
+ "`ls`, `cat`, and `rg` to inspect the sandbox workspace. Use `apply_patch` to create "
+ "`output/policy_findings.md` and `output/human_review_checklist.md`."
+ ),
+ capabilities=[
+ Shell(),
+ Filesystem(),
+ Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=skills_root))),
+ ],
+ model_settings=ModelSettings(
+ reasoning=Reasoning(effort="low"),
+ verbosity="low",
+ tool_choice="required",
+ ),
+ output_type=AgentOutputSchema(SandboxPolicyPacket, strict_json_schema=False),
+ )
+
+
+def build_orchestrator(*, sandbox_policy_tool: Tool) -> Agent[HealthcareSupportContext]:
+ return Agent[HealthcareSupportContext](
+ name="HealthcareSupportOrchestrator",
+ model="gpt-5.4",
+ instructions=ORCHESTRATOR_PROMPT,
+ model_settings=ModelSettings(
+ reasoning=Reasoning(effort="low"),
+ verbosity="low",
+ ),
+ tools=[
+ benefits_agent.as_tool(
+ tool_name="benefits_review",
+ tool_description="Review patient eligibility, benefits, and referral status.",
+ ),
+ sandbox_policy_tool,
+ route_to_human_queue,
+ ],
+ output_type=AgentOutputSchema(CaseResolution, strict_json_schema=False),
+ )
+
+
+memory_recap_agent = Agent[HealthcareSupportContext](
+ name="HealthcareSupportMemoryAgent",
+ model="gpt-5.4",
+ instructions=MEMORY_PROMPT,
+ model_settings=ModelSettings(reasoning=Reasoning(effort="low"), verbosity="low"),
+ output_type=AgentOutputSchema(MemoryRecap, strict_json_schema=False),
+)
diff --git a/examples/sandbox/healthcare_support/tools.py b/examples/sandbox/healthcare_support/tools.py
new file mode 100644
index 00000000..571485e2
--- /dev/null
+++ b/examples/sandbox/healthcare_support/tools.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from typing import Any
+
+from agents import RunContextWrapper, function_tool
+from examples.sandbox.healthcare_support.data import HealthcareSupportDataStore
+from examples.sandbox.healthcare_support.models import ScenarioCase
+
+
+@dataclass
+class HealthcareSupportContext:
+ store: HealthcareSupportDataStore
+ scenario: ScenarioCase
+ session_id: str = ""
+ human_handoffs: list[dict[str, Any]] = field(default_factory=list)
+ human_handoff_approved: bool = False
+ emit_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None
+
+ async def emit(self, event_name: str, **payload: Any) -> None:
+ if self.emit_event is None:
+ return
+ await self.emit_event(
+ {
+ "type": "workflow_event",
+ "event": event_name,
+ **payload,
+ }
+ )
+
+
+@function_tool(name_override="patient_info_lookup")
+def lookup_patient(
+ context: RunContextWrapper[HealthcareSupportContext],
+ patient_id: str | None = None,
+ phone: str | None = None,
+ name: str | None = None,
+) -> dict[str, Any]:
+ """Look up a synthetic patient profile by patient ID, phone, or name."""
+ return context.context.store.lookup_patient(
+ patient_id=patient_id,
+ phone=phone,
+ name=name,
+ )
+
+
+@function_tool(name_override="insurance_eligibility_lookup")
+def lookup_insurance_eligibility(
+ context: RunContextWrapper[HealthcareSupportContext],
+ payer: str | None = None,
+ member_id: str | None = None,
+ dob: str | None = None,
+) -> dict[str, Any]:
+ """Look up synthetic insurance eligibility by payer, member ID, and DOB."""
+ return context.context.store.lookup_eligibility(
+ payer=payer,
+ member_id=member_id,
+ dob=dob,
+ )
+
+
+@function_tool(name_override="appointment_referral_status_lookup")
+def lookup_referral_status(
+ context: RunContextWrapper[HealthcareSupportContext],
+ referral_id: str | None = None,
+ patient_id: str | None = None,
+) -> dict[str, Any]:
+ """Look up synthetic referral status by referral ID or patient ID."""
+ return context.context.store.lookup_referral(
+ referral_id=referral_id,
+ patient_id=patient_id,
+ )
+
+
+async def _needs_human_approval(
+ context: RunContextWrapper[HealthcareSupportContext],
+ _params: dict[str, Any],
+ _call_id: str,
+) -> bool:
+ return not context.context.human_handoff_approved
+
+
+@function_tool(name_override="route_to_human_queue", needs_approval=_needs_human_approval)
+def route_to_human_queue(
+ context: RunContextWrapper[HealthcareSupportContext],
+ queue: str,
+ priority: str,
+ reason: str,
+ summary: str,
+) -> dict[str, Any]:
+ """Route a synthetic case to a human queue after explicit approval."""
+ payload = {
+ "queue": queue,
+ "priority": priority,
+ "reason": reason,
+ "summary": summary,
+ "scenario_id": context.context.scenario.scenario_id,
+ }
+ digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:12]
+ result = {
+ "status": "queued",
+ "handoff_id": f"HUMAN-{digest.upper()}",
+ "queue": queue,
+ "priority": priority,
+ "reason": reason,
+ "summary": summary,
+ }
+ context.context.human_handoffs.append({"payload": payload, "result": result})
+ return result
diff --git a/examples/sandbox/healthcare_support/workflow.py b/examples/sandbox/healthcare_support/workflow.py
new file mode 100644
index 00000000..7306ec65
--- /dev/null
+++ b/examples/sandbox/healthcare_support/workflow.py
@@ -0,0 +1,414 @@
+from __future__ import annotations
+
+import json
+from collections.abc import Awaitable, Callable
+from pathlib import Path
+from typing import Any, cast
+
+from pydantic import BaseModel
+
+from agents import (
+ Agent,
+ AgentHookContext,
+ RunContextWrapper,
+ RunHooks,
+ Runner,
+ SQLiteSession,
+ Tool,
+ gen_trace_id,
+ trace,
+)
+from agents.run import RunConfig
+from agents.sandbox import Manifest, SandboxRunConfig
+from agents.sandbox.entries import Dir, File, LocalDir
+from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
+from agents.tool_context import ToolContext
+from examples.sandbox.healthcare_support.data import HealthcareSupportDataStore
+from examples.sandbox.healthcare_support.models import (
+ CaseResolution,
+ MemoryRecap,
+ ScenarioCase,
+)
+from examples.sandbox.healthcare_support.support_agents import (
+ build_orchestrator,
+ build_policy_sandbox_agent,
+ memory_recap_agent,
+)
+from examples.sandbox.healthcare_support.tools import HealthcareSupportContext
+
+EXAMPLE_ROOT = Path(__file__).resolve().parent
+POLICIES_ROOT = EXAMPLE_ROOT / "policies"
+SKILLS_ROOT = EXAMPLE_ROOT / "skills"
+SDK_ROOT = EXAMPLE_ROOT.parents[2]
+CACHE_ROOT = SDK_ROOT / ".cache" / "healthcare_support"
+SESSION_DB_PATH = CACHE_ROOT / "sessions.db"
+DEFAULT_SESSION_ID = "healthcare-support-demo-memory"
+
+ApprovalHandler = Callable[[dict[str, Any]], Awaitable[bool]]
+
+
+class WorkflowHooks(RunHooks[HealthcareSupportContext]):
+ async def on_agent_start(
+ self,
+ context: AgentHookContext[HealthcareSupportContext],
+ agent: Agent[HealthcareSupportContext],
+ ) -> None:
+ await context.context.emit("agent_start", agent=agent.name)
+
+ async def on_agent_end(
+ self,
+ context: RunContextWrapper[HealthcareSupportContext],
+ agent: Agent[HealthcareSupportContext],
+ output: Any,
+ ) -> None:
+ await context.context.emit(
+ "agent_end",
+ agent=agent.name,
+ output=_to_jsonable(output),
+ )
+
+ async def on_tool_start(
+ self,
+ context: RunContextWrapper[HealthcareSupportContext],
+ agent: Agent[HealthcareSupportContext],
+ tool: Tool,
+ ) -> None:
+ tool_context = cast(ToolContext[HealthcareSupportContext], context)
+ await context.context.emit(
+ "tool_start",
+ agent=agent.name,
+ tool=tool.name,
+ call_id=tool_context.tool_call_id,
+ arguments=tool_context.tool_arguments,
+ )
+
+ async def on_tool_end(
+ self,
+ context: RunContextWrapper[HealthcareSupportContext],
+ agent: Agent[HealthcareSupportContext],
+ tool: Tool,
+ result: str,
+ ) -> None:
+ tool_context = cast(ToolContext[HealthcareSupportContext], context)
+ await context.context.emit(
+ "tool_end",
+ agent=agent.name,
+ tool=tool.name,
+ call_id=tool_context.tool_call_id,
+ output=_to_jsonable(result),
+ )
+
+
+def _to_jsonable(value: Any) -> Any:
+ if isinstance(value, BaseModel):
+ return value.model_dump(mode="json")
+ if isinstance(value, dict | list | str | int | float | bool) or value is None:
+ return value
+ try:
+ return json.loads(json.dumps(value, default=str))
+ except Exception:
+ return str(value)
+
+
+def build_context(
+ *,
+ store: HealthcareSupportDataStore,
+ scenario_id: str = "eligibility_verification_basic",
+ session_id: str = DEFAULT_SESSION_ID,
+ emit_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
+) -> HealthcareSupportContext:
+ return HealthcareSupportContext(
+ store=store,
+ scenario=store.get_scenario(scenario_id),
+ session_id=session_id,
+ emit_event=emit_event,
+ )
+
+
+def _build_manifest(scenario: ScenarioCase) -> Manifest:
+ return Manifest(
+ entries={
+ "case": Dir(
+ children={
+ "scenario.json": File(
+ content=json.dumps(scenario.model_dump(mode="json"), indent=2).encode(
+ "utf-8"
+ )
+ ),
+ "transcript.txt": File(content=scenario.transcript.encode("utf-8")),
+ },
+ description="Synthetic support request and scenario metadata.",
+ ),
+ "policies": LocalDir(
+ src=POLICIES_ROOT,
+ description="Local healthcare policy and workflow documents.",
+ ),
+ "output": Dir(description="Generated support artifacts for this case."),
+ }
+ )
+
+
+async def _structured_tool_output_extractor(result: Any) -> str:
+ final_output = result.final_output
+ if isinstance(final_output, BaseModel):
+ return json.dumps(final_output.model_dump(mode="json"), sort_keys=True)
+ return str(final_output)
+
+
+def _fallback_artifacts(*, scenario: ScenarioCase, resolution: CaseResolution) -> dict[str, str]:
+ policy_doc = f"""# Policy Findings
+
+## Case
+{scenario.description}
+
+## Policy summary
+{resolution.policy_summary}
+
+## Next step
+{resolution.next_step}
+"""
+ checklist_doc = f"""# Human Review Checklist
+
+- Confirm whether the request needs prior authorization for this service and payer.
+- Verify referral state and any missing clinical or billing identifiers.
+- Use this internal summary: {resolution.internal_summary}
+- Patient-facing response: {resolution.patient_facing_response}
+"""
+ return {
+ "policy_findings.md": policy_doc,
+ "human_review_checklist.md": checklist_doc,
+ }
+
+
+async def _copy_output_files(
+ *,
+ sandbox: Any,
+ scenario: ScenarioCase,
+ resolution: CaseResolution,
+) -> list[dict[str, str]]:
+ scenario_id = scenario.scenario_id
+ destination_root = CACHE_ROOT / "output" / scenario_id
+ destination_root.mkdir(parents=True, exist_ok=True)
+ copied_by_name: dict[str, dict[str, str]] = {}
+
+ for entry in await sandbox.ls("output"):
+ entry_path = Path(entry.path)
+ if entry.is_dir():
+ continue
+
+ handle = await sandbox.read(entry_path)
+ try:
+ payload = handle.read()
+ finally:
+ handle.close()
+
+ local_path = destination_root / entry_path.name
+ if isinstance(payload, str):
+ content = payload
+ local_path.write_text(content, encoding="utf-8")
+ else:
+ content = bytes(payload).decode("utf-8", errors="replace")
+ local_path.write_text(content, encoding="utf-8")
+
+ copied_by_name[entry_path.name] = {
+ "name": entry_path.name,
+ "path": str(local_path),
+ "content": content,
+ }
+
+ for filename, content in _fallback_artifacts(
+ scenario=scenario,
+ resolution=resolution,
+ ).items():
+ if filename in copied_by_name:
+ continue
+ local_path = destination_root / filename
+ local_path.write_text(content, encoding="utf-8")
+ copied_by_name[filename] = {
+ "name": filename,
+ "path": str(local_path),
+ "content": content,
+ }
+
+ return [copied_by_name[name] for name in sorted(copied_by_name)]
+
+
+async def _resolve_interruptions(
+ *,
+ result: Any,
+ orchestrator: Agent[HealthcareSupportContext],
+ context: HealthcareSupportContext,
+ conversation_session: SQLiteSession,
+ hooks: WorkflowHooks,
+ approval_handler: ApprovalHandler | None,
+) -> Any:
+ approval_round = 0
+ while result.interruptions:
+ approval_round += 1
+ if approval_round > 5:
+ raise RuntimeError("Exceeded 5 approval rounds while resuming the workflow.")
+
+ state = result.to_state()
+ CACHE_ROOT.mkdir(parents=True, exist_ok=True)
+ state_payload = state.to_json(
+ context_serializer=lambda value: {
+ "scenario_id": value.scenario.scenario_id,
+ "session_id": value.session_id,
+ "human_handoffs": value.human_handoffs,
+ }
+ )
+ (CACHE_ROOT / "pending_state.json").write_text(
+ json.dumps(state_payload, indent=2),
+ encoding="utf-8",
+ )
+
+ for interruption in result.interruptions:
+ request = {
+ "agent": interruption.agent.name,
+ "tool": interruption.name,
+ "arguments": _to_jsonable(interruption.arguments),
+ }
+ await context.emit("human_approval_requested", request=request)
+ approved = True if approval_handler is None else await approval_handler(request)
+
+ if approved:
+ context.human_handoff_approved = True
+ state.approve(interruption, always_approve=False)
+ await context.emit("human_approval_resolved", approved=True, request=request)
+ else:
+ context.human_handoff_approved = False
+ state.reject(interruption)
+ await context.emit("human_approval_resolved", approved=False, request=request)
+
+ result = await Runner.run(
+ orchestrator,
+ state,
+ session=conversation_session,
+ hooks=hooks,
+ )
+ return result
+
+
+def _workflow_prompt(scenario: ScenarioCase) -> str:
+ return json.dumps(
+ {
+ "scenario_id": scenario.scenario_id,
+ "description": scenario.description,
+ "transcript": scenario.transcript,
+ "patient_metadata": scenario.patient_metadata,
+ "followup_answers": scenario.followup_qa,
+ },
+ indent=2,
+ )
+
+
+async def run_healthcare_support_workflow(
+ *,
+ context: HealthcareSupportContext,
+ scenario_id: str,
+ approval_handler: ApprovalHandler | None = None,
+) -> dict[str, Any]:
+ scenario = context.store.get_scenario(scenario_id)
+ context.scenario = scenario
+ context.human_handoffs.clear()
+ context.human_handoff_approved = False
+
+ await context.emit(
+ "scenario_loaded",
+ scenario_id=scenario.scenario_id,
+ description=scenario.description,
+ transcript=scenario.transcript,
+ )
+
+ CACHE_ROOT.mkdir(parents=True, exist_ok=True)
+ conversation_session = SQLiteSession(
+ session_id=context.session_id or DEFAULT_SESSION_ID, db_path=SESSION_DB_PATH
+ )
+ await context.emit("memory_ready", session_id=conversation_session.session_id)
+
+ hooks = WorkflowHooks()
+ sandbox_client = UnixLocalSandboxClient()
+ sandbox = await sandbox_client.create(manifest=_build_manifest(scenario))
+ await context.emit(
+ "sandbox_ready",
+ backend="unix_local",
+ workspace=["case/scenario.json", "case/transcript.txt", "policies/", "output/"],
+ )
+
+ policy_agent = build_policy_sandbox_agent(skills_root=SKILLS_ROOT)
+ sandbox_policy_tool = policy_agent.as_tool(
+ tool_name="sandbox_policy_packet",
+ tool_description="Inspect policy files in a sandbox and generate support artifacts.",
+ custom_output_extractor=_structured_tool_output_extractor,
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ workflow_name="Healthcare support sandbox packet",
+ ),
+ hooks=hooks,
+ )
+ orchestrator = build_orchestrator(sandbox_policy_tool=sandbox_policy_tool)
+ trace_id = gen_trace_id()
+ trace_url = f"https://platform.openai.com/traces/trace?trace_id={trace_id}"
+
+ try:
+ async with sandbox:
+ await context.emit("trace_ready", trace_id=trace_id, trace_url=trace_url)
+ with trace(
+ "Healthcare support workflow",
+ trace_id=trace_id,
+ group_id=scenario.scenario_id,
+ ):
+ result = await Runner.run(
+ orchestrator,
+ _workflow_prompt(scenario),
+ context=context,
+ session=conversation_session,
+ hooks=hooks,
+ )
+ result = await _resolve_interruptions(
+ result=result,
+ orchestrator=orchestrator,
+ context=context,
+ conversation_session=conversation_session,
+ hooks=hooks,
+ approval_handler=approval_handler,
+ )
+ resolution = result.final_output_as(CaseResolution)
+
+ copied_files = await _copy_output_files(
+ sandbox=sandbox,
+ scenario=scenario,
+ resolution=resolution,
+ )
+ await context.emit("artifacts_ready", files=copied_files)
+
+ memory_result = await Runner.run(
+ memory_recap_agent,
+ (
+ "Summarize what you remember from the session. Include patient, intent, "
+ "handoff state, generated files, and next step."
+ ),
+ context=context,
+ session=conversation_session,
+ hooks=hooks,
+ )
+ recap = memory_result.final_output_as(MemoryRecap)
+
+ history_items = await conversation_session.get_items()
+ payload = {
+ "scenario_id": scenario.scenario_id,
+ "description": scenario.description,
+ "transcript": scenario.transcript,
+ "trace_id": trace_id,
+ "trace_url": trace_url,
+ "resolution": resolution.model_dump(mode="json"),
+ "memory_recap": recap.model_dump(mode="json"),
+ "artifacts": copied_files,
+ "session_id": conversation_session.session_id,
+ "session_memory_items": len(history_items),
+ }
+ await context.emit("workflow_complete", payload=payload)
+ return payload
+ finally:
+ await sandbox_client.delete(sandbox)
+ await context.emit("sandbox_stopped", backend="unix_local")
diff --git a/examples/sandbox/memory.py b/examples/sandbox/memory.py
new file mode 100644
index 00000000..4c0f7070
--- /dev/null
+++ b/examples/sandbox/memory.py
@@ -0,0 +1,222 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+import tempfile
+from pathlib import Path
+
+from agents import Runner
+from agents.run import RunConfig
+from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
+from agents.sandbox.capabilities import Filesystem, Memory, Shell
+from agents.sandbox.entries import File
+from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
+from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+DEFAULT_MODEL = "gpt-5.4"
+FIRST_PROMPT = "Inspect workspace and fix invoice total bug in src/acme_metrics/report.py."
+SECOND_PROMPT = "Add a regression test for the previous bug you fixed."
+
+
+def _build_manifest() -> Manifest:
+ return Manifest(
+ entries={
+ "README.md": File(
+ content=(
+ b"# Acme Metrics\n\n"
+ b"Small demo package for validating invoice total formatting.\n"
+ )
+ ),
+ "pyproject.toml": File(
+ content=(
+ b"[project]\n"
+ b'name = "acme-metrics"\n'
+ b'version = "0.1.0"\n'
+ b'requires-python = ">=3.10"\n'
+ b"\n"
+ b"[tool.pytest.ini_options]\n"
+ b'pythonpath = ["src"]\n'
+ )
+ ),
+ "src/acme_metrics/__init__.py": File(
+ content=b"from .report import format_invoice_total\n"
+ ),
+ "src/acme_metrics/report.py": File(
+ content=(
+ b"from __future__ import annotations\n\n"
+ b"def format_invoice_total(subtotal: float, tax_rate: float) -> str:\n"
+ b" total = subtotal + tax_rate\n"
+ b' return f"${total:.2f}"\n'
+ )
+ ),
+ "tests/test_report.py": File(
+ content=(
+ b"from acme_metrics import format_invoice_total\n\n\n"
+ b"def test_format_invoice_total_applies_tax_rate() -> None:\n"
+ b' assert format_invoice_total(100.0, 0.075) == "$107.50"\n'
+ )
+ ),
+ }
+ )
+
+
+def _build_agent(*, model: str, manifest: Manifest) -> SandboxAgent:
+ # This one user-facing agent can read existing memory, update stale memory in place, and
+ # generate new background memories when the sandbox session closes.
+ return SandboxAgent(
+ name="Sandbox Memory Demo",
+ model=model,
+ instructions=(
+ "Answer questions about the sandbox workspace. Inspect files before answering, make "
+ "minimal edits, and keep the response concise. "
+ "Use the shell tool to inspect and validate the workspace. Use apply_patch for text "
+ "edits when it is the clearest option. Do not invent files you did not read."
+ ),
+ default_manifest=manifest,
+ capabilities=[
+ # `Memory()` enables both read and generate behavior with live updates on by default.
+ Memory(),
+ Filesystem(),
+ Shell(),
+ ],
+ # `Memory()` is the recommended default. If you need to tune the behavior, you can switch
+ # to an explicit config such as:
+ #
+ # Memory(
+ # layout=MemoryLayoutConfig(memories_dir="agent_memory", sessions_dir="agent_sessions"),
+ # read=MemoryReadConfig(live_update=False),
+ # generate=MemoryGenerateConfig(max_raw_memories_for_consolidation=128),
+ # )
+ #
+ # `generate.max_raw_memories_for_consolidation`: cap how many recent raw memories are
+ # considered during consolidation. Older conversation-specific guidance may be removed from
+ # consolidated memory when the cap is exceeded.
+ #
+ # Multi-turn conversations work best when all turns share the same live sandbox session and
+ # an SDK Session. The SDK session_id groups those runs into one memory conversation. Without
+ # an SDK session, sandbox memory falls back to OpenAI conversation_id, then RunConfig
+ # group_id, then one generated memory conversation for each Runner.run().
+ #
+ # `read.live_update=False`: use this when the agent should not repair stale memory during
+ # the run. That can save a few seconds, but stale memory debt can accumulate until a later
+ # consolidation, which may or may not catch the staleness. It also prevents the agent from
+ # updating memory immediately during the run, including when the user explicitly asks it to
+ # remember something new or revise existing memory.
+ #
+ # If you need additional memory-generation guidance, `generate.extra_prompt` is appended to the
+ # built-in memory prompt. Keep it short, ideally a few focused bullets and well under ~5k
+ # tokens, so the model still pays attention to the conversation evidence.
+ #
+ # Memory(
+ # generate=MemoryGenerateConfig(
+ # extra_prompt="Pay extra attention to documenting what bug was fixed and why it happened."
+ # )
+ # )
+ )
+
+
+def _artifact_paths(
+ *, memories_dir: str = "memories", sessions_dir: str = "sessions"
+) -> tuple[Path, ...]:
+ return (
+ Path(sessions_dir),
+ Path(memories_dir) / "MEMORY.md",
+ Path(memories_dir) / "memory_summary.md",
+ Path(memories_dir) / "raw_memories.md",
+ Path(memories_dir) / "raw_memories",
+ Path(memories_dir) / "rollout_summaries",
+ )
+
+
+def _print_memory_tree(workspace_root: Path) -> None:
+ print("\nGenerated memory artifacts:")
+ for relative_path in _artifact_paths():
+ full_path = workspace_root / relative_path
+ if not full_path.exists():
+ print(f"- {relative_path} (missing)")
+ continue
+
+ if full_path.is_dir():
+ print(f"- {relative_path}/")
+ for child in sorted(full_path.iterdir()):
+ print(f" - {relative_path / child.name}")
+ if relative_path == Path("sessions"):
+ contents = child.read_text().rstrip()
+ if not contents:
+ print(" (empty)")
+ else:
+ for line in contents.splitlines():
+ print(f" {line}")
+ continue
+
+ print(f"- {relative_path}")
+ print(full_path.read_text().rstrip() or "(empty)")
+
+
+def _run_config(*, sandbox: BaseSandboxSession, workflow_name: str) -> RunConfig:
+ return RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ workflow_name=workflow_name,
+ tracing_disabled=True,
+ )
+
+
+async def main(*, model: str) -> None:
+ manifest = _build_manifest()
+ agent = _build_agent(model=model, manifest=manifest)
+ client = UnixLocalSandboxClient()
+
+ with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_dir:
+ # Use a local snapshot so the second run resumes the same workspace in a new sandbox
+ # session. That makes the second prompt rely on memory instead of in-process agent state.
+ sandbox = await client.create(
+ manifest=manifest,
+ snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
+ )
+ workspace_root = Path(sandbox.state.manifest.root)
+
+ try:
+ async with sandbox:
+ # Run 1 fixes the bug and generates memory artifacts when the session closes.
+ first = await Runner.run(
+ agent,
+ FIRST_PROMPT,
+ run_config=_run_config(
+ sandbox=sandbox,
+ workflow_name="Sandbox memory example: initial fix",
+ ),
+ )
+ print("\n[first run]")
+ print(first.final_output)
+
+ resumed_sandbox = await client.resume(sandbox.state)
+ async with resumed_sandbox:
+ # Run 2 starts from the resumed snapshot and reads the memory generated by run 1
+ # before answering the follow-up prompt.
+ second = await Runner.run(
+ agent,
+ SECOND_PROMPT,
+ run_config=_run_config(
+ sandbox=resumed_sandbox,
+ workflow_name="Sandbox memory example: follow-up",
+ ),
+ )
+ print("\n[second run]")
+ print(second.final_output)
+
+ _print_memory_tree(workspace_root)
+ finally:
+ await client.delete(sandbox)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Run one sandbox agent twice across a snapshot resume with shared memory."
+ )
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
+ args = parser.parse_args()
+ asyncio.run(main(model=args.model))
diff --git a/examples/sandbox/memory_multi_agent_multiturn.py b/examples/sandbox/memory_multi_agent_multiturn.py
new file mode 100644
index 00000000..e7e867b3
--- /dev/null
+++ b/examples/sandbox/memory_multi_agent_multiturn.py
@@ -0,0 +1,231 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+from pathlib import Path
+
+from agents import Runner, SQLiteSession
+from agents.run import RunConfig
+from agents.sandbox import Manifest, MemoryLayoutConfig, SandboxAgent, SandboxRunConfig
+from agents.sandbox.capabilities import Filesystem, Memory, Shell
+from agents.sandbox.entries import Dir, File
+from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+DEFAULT_MODEL = "gpt-5.4"
+GTM_SESSION_ID = "gtm-q2-pipeline-review"
+ENGINEERING_SESSION_ID = "eng-invoice-test-fix"
+
+GTM_TURN_1 = (
+ "Analyze data/leads.csv. Find one promising GTM segment, explain why, and say what "
+ "follow-up data you need."
+)
+GTM_TURN_2 = (
+ "Using your previous GTM analysis, write a short outreach hypothesis and save it to "
+ "gtm_hypothesis.md."
+)
+ENGINEERING_TURN = (
+ "Fix the invoice total bug in src/acme_metrics/report.py, then run the test suite."
+)
+
+
+def _build_manifest() -> Manifest:
+ return Manifest(
+ entries={
+ "data": Dir(
+ children={
+ "leads.csv": File(
+ content=(
+ b"account,segment,seats,trial_events,monthly_spend\n"
+ b"Northstar Health,healthcare,240,98,18000\n"
+ b"Beacon Retail,retail,75,18,4200\n"
+ b"Apex Fintech,financial-services,180,76,13500\n"
+ b"Summit Labs,healthcare,52,22,3900\n"
+ )
+ )
+ }
+ ),
+ "pyproject.toml": File(
+ content=(
+ b"[project]\n"
+ b'name = "acme-metrics"\n'
+ b'version = "0.1.0"\n'
+ b'requires-python = ">=3.10"\n'
+ b"\n"
+ b"[tool.pytest.ini_options]\n"
+ b'pythonpath = ["src"]\n'
+ )
+ ),
+ "src": Dir(
+ children={
+ "acme_metrics": Dir(
+ children={
+ "__init__.py": File(
+ content=b"from .report import format_invoice_total\n"
+ ),
+ "report.py": File(
+ content=(
+ b"from __future__ import annotations\n\n"
+ b"def format_invoice_total(subtotal: float, tax_rate: float) -> str:\n"
+ b" total = subtotal + tax_rate\n"
+ b' return f"${total:.2f}"\n'
+ )
+ ),
+ }
+ )
+ }
+ ),
+ "tests": Dir(
+ children={
+ "test_report.py": File(
+ content=(
+ b"from acme_metrics import format_invoice_total\n\n\n"
+ b"def test_format_invoice_total_applies_tax_rate() -> None:\n"
+ b' assert format_invoice_total(100.0, 0.075) == "$107.50"\n'
+ )
+ )
+ }
+ ),
+ }
+ )
+
+
+def _build_gtm_agent(*, model: str, manifest: Manifest) -> SandboxAgent:
+ return SandboxAgent(
+ name="GTM analyst",
+ model=model,
+ instructions=(
+ "You are a GTM analyst. Inspect the workspace data before answering. Keep analysis "
+ "specific and cite file paths you used."
+ ),
+ default_manifest=manifest,
+ capabilities=[
+ # Same layout + same SDK session across turns means one memory conversation.
+ Memory(
+ layout=MemoryLayoutConfig(
+ memories_dir="memories/gtm",
+ sessions_dir="sessions/gtm",
+ )
+ ),
+ Filesystem(),
+ Shell(),
+ Filesystem(),
+ ],
+ )
+
+
+def _build_engineering_agent(*, model: str, manifest: Manifest) -> SandboxAgent:
+ return SandboxAgent(
+ name="Engineering fixer",
+ model=model,
+ instructions=(
+ "You are an engineer. Inspect files before editing, make minimal changes, and verify "
+ "with tests."
+ ),
+ default_manifest=manifest,
+ capabilities=[
+ # Different layout keeps engineering memory separate even in the same sandbox workspace.
+ Memory(
+ layout=MemoryLayoutConfig(
+ memories_dir="memories/engineering",
+ sessions_dir="sessions/engineering",
+ )
+ ),
+ Shell(),
+ Filesystem(),
+ ],
+ )
+
+
+def _print_tree(
+ root: Path, label: str, relative_path: str, *, print_file_contents: bool = False
+) -> None:
+ print(f"\n[{label}]")
+ base = root / relative_path
+ if not base.exists():
+ print(f"{relative_path} (missing)")
+ return
+ for path in sorted(base.rglob("*")):
+ if path.is_file():
+ print(path.relative_to(root))
+ if print_file_contents:
+ contents = path.read_text().rstrip()
+ if not contents:
+ print(" (empty)")
+ else:
+ for line in contents.splitlines():
+ print(f" {line}")
+
+
+async def main(*, model: str) -> None:
+ manifest = _build_manifest()
+ gtm_agent = _build_gtm_agent(model=model, manifest=manifest)
+ engineering_agent = _build_engineering_agent(model=model, manifest=manifest)
+ client = UnixLocalSandboxClient()
+ sandbox = await client.create(manifest=manifest)
+ workspace_root = Path(sandbox.state.manifest.root)
+
+ try:
+ async with sandbox:
+ gtm_conversation_session = SQLiteSession(GTM_SESSION_ID)
+ gtm_config = RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ workflow_name="GTM memory layout example",
+ )
+ gtm_first = await Runner.run(
+ gtm_agent,
+ GTM_TURN_1,
+ session=gtm_conversation_session,
+ run_config=gtm_config,
+ )
+ print("\n[gtm turn 1]")
+ print(gtm_first.final_output)
+
+ # Reuse the SDK session so the model sees prior turns and memory extracts them together.
+ gtm_second = await Runner.run(
+ gtm_agent,
+ GTM_TURN_2,
+ session=gtm_conversation_session,
+ run_config=gtm_config,
+ )
+ print("\n[gtm turn 2]")
+ print(gtm_second.final_output)
+
+ engineering_conversation_session = SQLiteSession(ENGINEERING_SESSION_ID)
+ engineering_config = RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ workflow_name="Engineering memory layout example",
+ )
+ engineering = await Runner.run(
+ engineering_agent,
+ ENGINEERING_TURN,
+ session=engineering_conversation_session,
+ run_config=engineering_config,
+ )
+ print("\n[engineering]")
+ print(engineering.final_output)
+
+ _print_tree(workspace_root, "gtm memory", "memories/gtm")
+ _print_tree(workspace_root, "engineering memory", "memories/engineering")
+ _print_tree(workspace_root, "gtm sessions", "sessions/gtm", print_file_contents=True)
+ _print_tree(
+ workspace_root,
+ "engineering sessions",
+ "sessions/engineering",
+ print_file_contents=True,
+ )
+ finally:
+ await client.delete(sandbox)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Run two sandbox agents with separate memory layouts in one workspace."
+ )
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
+ args = parser.parse_args()
+
+ asyncio.run(main(model=args.model))
diff --git a/examples/sandbox/memory_s3.py b/examples/sandbox/memory_s3.py
new file mode 100644
index 00000000..2eb3bea5
--- /dev/null
+++ b/examples/sandbox/memory_s3.py
@@ -0,0 +1,329 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import os
+import sys
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+
+from agents import Runner
+from agents.run import RunConfig
+from agents.sandbox import (
+ Manifest,
+ MemoryGenerateConfig,
+ MemoryLayoutConfig,
+ SandboxAgent,
+ SandboxRunConfig,
+)
+from agents.sandbox.capabilities import Filesystem, Memory, Shell
+from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount
+from agents.sandbox.sandboxes.docker import (
+ DockerSandboxClient,
+ DockerSandboxClientOptions,
+)
+from agents.sandbox.session import SandboxSession
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from examples.sandbox.basic import _import_docker_from_env
+from examples.sandbox.docker.mounts.mount_smoke import IMAGE as MOUNT_IMAGE, ensure_mount_image
+
+DEFAULT_MODEL = "gpt-5.4"
+DEFAULT_MOUNT_DIR = "persistent"
+FIRST_PROMPT = "Inspect workspace and fix invoice total bug in src/acme_metrics/report.py."
+SECOND_PROMPT = (
+ "Add a regression test for the previous bug you fixed. Put it in "
+ "tests/test_invoice_regression.py."
+)
+MEMORY_EXTRA_PROMPT = (
+ "This is an S3-backed memory demo. If a run fixes a concrete code bug, remember the "
+ "specific file path, test expectation, root cause, and patch so a future fresh sandbox can "
+ "reuse the fix instead of rediscovering it."
+)
+
+
+@dataclass(frozen=True)
+class S3MemoryExampleConfig:
+ bucket: str
+ access_key_id: str | None
+ secret_access_key: str | None
+ session_token: str | None
+ region: str | None
+ endpoint_url: str | None
+ prefix: str
+
+ @classmethod
+ def from_env(cls, *, prefix: str | None = None) -> S3MemoryExampleConfig:
+ bucket = os.getenv("S3_BUCKET") or os.getenv("S3_MOUNT_BUCKET")
+ if not bucket:
+ raise SystemExit(
+ "Missing S3 bucket name. Set S3_BUCKET or S3_MOUNT_BUCKET. "
+ "This example works well with: source ~/.s3.env"
+ )
+ resolved_prefix = (
+ prefix
+ or os.getenv("S3_MOUNT_PREFIX", f"sandbox-memory-example/{uuid.uuid4().hex}")
+ or f"sandbox-memory-example/{uuid.uuid4().hex}"
+ )
+ return cls(
+ 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"),
+ region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
+ endpoint_url=os.getenv("S3_ENDPOINT_URL"),
+ prefix=resolved_prefix.strip("/"),
+ )
+
+
+def _persistent_layout(*, mount_dir: str = DEFAULT_MOUNT_DIR) -> MemoryLayoutConfig:
+ return MemoryLayoutConfig(
+ memories_dir=f"{mount_dir}/memories",
+ sessions_dir=f"{mount_dir}/sessions",
+ )
+
+
+def _artifact_paths(*, mount_dir: str = DEFAULT_MOUNT_DIR) -> tuple[Path, ...]:
+ layout = _persistent_layout(mount_dir=mount_dir)
+ return (
+ Path(layout.sessions_dir),
+ Path(layout.memories_dir) / "MEMORY.md",
+ Path(layout.memories_dir) / "memory_summary.md",
+ Path(layout.memories_dir) / "raw_memories.md",
+ Path(layout.memories_dir) / "raw_memories",
+ Path(layout.memories_dir) / "rollout_summaries",
+ )
+
+
+def _build_manifest(
+ *, config: S3MemoryExampleConfig, mount_dir: str = DEFAULT_MOUNT_DIR
+) -> Manifest:
+ return Manifest(
+ entries={
+ "README.md": File(
+ content=(
+ b"# Acme Metrics\n\n"
+ b"Small demo package for validating invoice total formatting.\n"
+ )
+ ),
+ "pyproject.toml": File(
+ content=(
+ b"[project]\n"
+ b'name = "acme-metrics"\n'
+ b'version = "0.1.0"\n'
+ b'requires-python = ">=3.10"\n'
+ b"\n"
+ b"[tool.pytest.ini_options]\n"
+ b'pythonpath = ["src"]\n'
+ )
+ ),
+ "src/acme_metrics/__init__.py": File(
+ content=b"from .report import format_invoice_total\n"
+ ),
+ "src/acme_metrics/report.py": File(
+ content=(
+ b"from __future__ import annotations\n\n"
+ b"def format_invoice_total(subtotal: float, tax_rate: float) -> str:\n"
+ b" total = subtotal + tax_rate\n"
+ b' return f"${total:.2f}"\n'
+ )
+ ),
+ "tests/test_report.py": File(
+ content=(
+ b"from acme_metrics import format_invoice_total\n\n\n"
+ b"def test_format_invoice_total_applies_tax_rate() -> None:\n"
+ b' assert format_invoice_total(100.0, 0.075) == "$107.50"\n'
+ )
+ ),
+ mount_dir: S3Mount(
+ bucket=config.bucket,
+ access_key_id=config.access_key_id,
+ secret_access_key=config.secret_access_key,
+ session_token=config.session_token,
+ prefix=config.prefix,
+ region=config.region,
+ endpoint_url=config.endpoint_url,
+ mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
+ read_only=False,
+ ),
+ }
+ )
+
+
+def _build_agent(
+ *, model: str, manifest: Manifest, mount_dir: str = DEFAULT_MOUNT_DIR
+) -> SandboxAgent:
+ return SandboxAgent(
+ name="Sandbox Memory S3 Demo",
+ model=model,
+ instructions=(
+ "Answer questions about the sandbox workspace. Inspect files before answering, make "
+ "minimal edits, and keep the response concise. "
+ "Use the shell tool to inspect and validate the workspace. Use apply_patch for text "
+ "edits when it is the clearest option. Do not invent files you did not read."
+ ),
+ default_manifest=manifest,
+ capabilities=[
+ Memory(
+ layout=_persistent_layout(mount_dir=mount_dir),
+ generate=MemoryGenerateConfig(extra_prompt=MEMORY_EXTRA_PROMPT),
+ ),
+ Filesystem(),
+ Shell(),
+ ],
+ )
+
+
+def _run_config(*, sandbox: SandboxSession, workflow_name: str) -> RunConfig:
+ return RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ workflow_name=workflow_name,
+ tracing_disabled=True,
+ )
+
+
+async def _read_text(session: SandboxSession, path: str) -> str:
+ handle = await session.read(Path(path))
+ try:
+ payload = handle.read()
+ finally:
+ handle.close()
+ if isinstance(payload, bytes):
+ return payload.decode("utf-8")
+ return str(payload)
+
+
+async def _path_exists(session: SandboxSession, path: Path) -> bool:
+ result = await session.exec("test", "-e", str(path), shell=False)
+ return result.ok()
+
+
+async def _path_is_dir(session: SandboxSession, path: Path) -> bool:
+ result = await session.exec("test", "-d", str(path), shell=False)
+ return result.ok()
+
+
+async def _assert_fixed(session: SandboxSession) -> None:
+ report_py = await _read_text(session, "src/acme_metrics/report.py")
+ if "subtotal * (1 + tax_rate)" not in report_py:
+ raise RuntimeError("Sandbox did not apply expected invoice total fix.")
+
+
+async def _assert_memory_summary_generated(session: SandboxSession) -> None:
+ memory_summary = await _read_text(session, f"{DEFAULT_MOUNT_DIR}/memories/memory_summary.md")
+ if not memory_summary.strip():
+ raise RuntimeError(
+ "First sandbox session did not generate a memory summary in S3-backed storage."
+ )
+
+
+async def _assert_regression_test_added(session: SandboxSession) -> None:
+ test_path = Path("tests/test_invoice_regression.py")
+ if not await _path_exists(session, test_path):
+ raise RuntimeError("Sandbox did not add the expected regression test file.")
+
+ regression_test = await _read_text(session, str(test_path))
+ if "format_invoice_total" not in regression_test:
+ raise RuntimeError("Regression test does not exercise format_invoice_total.")
+
+
+async def _print_tree(session: SandboxSession, *, mount_dir: str = DEFAULT_MOUNT_DIR) -> None:
+ print("\nS3-backed memory artifacts:")
+ for relative_path in _artifact_paths(mount_dir=mount_dir):
+ if not await _path_exists(session, relative_path):
+ print(f"- {relative_path} (missing)")
+ continue
+ if await _path_is_dir(session, relative_path):
+ print(f"- {relative_path}/")
+ children = await session.ls(relative_path)
+ for child in sorted(children, key=lambda entry: entry.path):
+ child_name = Path(child.path).name
+ if child_name in {".", ".."}:
+ continue
+ print(f" - {relative_path / child_name}")
+ continue
+ print(f"- {relative_path}")
+ print((await _read_text(session, str(relative_path))).rstrip() or "(empty)")
+
+
+async def _create_session(*, manifest: Manifest) -> tuple[DockerSandboxClient, SandboxSession]:
+ docker_from_env = _import_docker_from_env()
+ docker_client = docker_from_env()
+ sandbox_client = DockerSandboxClient(docker_client)
+ sandbox = await sandbox_client.create(
+ manifest=manifest,
+ options=DockerSandboxClientOptions(image=MOUNT_IMAGE),
+ )
+ return sandbox_client, sandbox
+
+
+async def _print_persisted_tree(*, manifest: Manifest) -> None:
+ inspect_client, inspect_sandbox = await _create_session(manifest=manifest)
+ try:
+ async with inspect_sandbox:
+ await _print_tree(inspect_sandbox)
+ finally:
+ await inspect_client.delete(inspect_sandbox)
+
+
+async def main(*, model: str, prefix: str | None) -> None:
+ ensure_mount_image()
+ config = S3MemoryExampleConfig.from_env(prefix=prefix)
+ manifest = _build_manifest(config=config)
+ agent = _build_agent(model=model, manifest=manifest)
+
+ first_client, first_sandbox = await _create_session(manifest=manifest)
+ try:
+ async with first_sandbox:
+ first = await Runner.run(
+ agent,
+ FIRST_PROMPT,
+ run_config=_run_config(
+ sandbox=first_sandbox,
+ workflow_name="Sandbox memory S3 example: first sandbox",
+ ),
+ )
+ print("\n[first sandbox]")
+ print(first.final_output)
+ await _assert_fixed(first_sandbox)
+ finally:
+ await first_client.delete(first_sandbox)
+
+ second_client, second_sandbox = await _create_session(manifest=manifest)
+ try:
+ async with second_sandbox:
+ await _assert_memory_summary_generated(second_sandbox)
+
+ second = await Runner.run(
+ agent,
+ SECOND_PROMPT,
+ run_config=_run_config(
+ sandbox=second_sandbox,
+ workflow_name="Sandbox memory S3 example: second sandbox",
+ ),
+ )
+ print("\n[second sandbox]")
+ print(second.final_output)
+ await _assert_regression_test_added(second_sandbox)
+ finally:
+ await second_client.delete(second_sandbox)
+
+ await _print_persisted_tree(manifest=manifest)
+ print(f"\nS3 prefix: {config.prefix}")
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Run sandbox memory across two fresh Docker sandboxes with S3-backed storage."
+ )
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
+ parser.add_argument(
+ "--prefix",
+ default=None,
+ help="Optional S3 prefix for mounted memory artifacts. Defaults to a unique prefix.",
+ )
+ args = parser.parse_args()
+ asyncio.run(main(model=args.model, prefix=args.prefix))
diff --git a/examples/sandbox/misc/__init__.py b/examples/sandbox/misc/__init__.py
new file mode 100644
index 00000000..8a5a5231
--- /dev/null
+++ b/examples/sandbox/misc/__init__.py
@@ -0,0 +1 @@
+# Shared support code for sandbox examples.
diff --git a/examples/sandbox/misc/example_support.py b/examples/sandbox/misc/example_support.py
new file mode 100644
index 00000000..0f6a1bb0
--- /dev/null
+++ b/examples/sandbox/misc/example_support.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+
+from agents.sandbox import Manifest
+from agents.sandbox.entries import File
+
+
+def text_manifest(files: Mapping[str, str]) -> Manifest:
+ """Build a manifest from in-memory UTF-8 text files."""
+
+ return Manifest(
+ entries={path: File(content=contents.encode("utf-8")) for path, contents in files.items()}
+ )
+
+
+def tool_call_name(raw_item: object) -> str:
+ """Return a readable name for a raw tool call item."""
+
+ if isinstance(raw_item, dict):
+ name = raw_item.get("name")
+ item_type = raw_item.get("type")
+ else:
+ name = getattr(raw_item, "name", None)
+ item_type = getattr(raw_item, "type", None)
+
+ if isinstance(name, str) and name:
+ return name
+ if item_type == "shell_call":
+ return "shell"
+ if isinstance(item_type, str):
+ return item_type
+ return ""
diff --git a/examples/sandbox/misc/reference_policy_mcp_server.py b/examples/sandbox/misc/reference_policy_mcp_server.py
new file mode 100644
index 00000000..0e6486d5
--- /dev/null
+++ b/examples/sandbox/misc/reference_policy_mcp_server.py
@@ -0,0 +1,25 @@
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("Reference Policy Server")
+
+
+@mcp.tool()
+def get_policy_reference(topic: str) -> str:
+ """Return short internal policy guidance for a supported topic."""
+ normalized = topic.strip().lower()
+ if "discount" in normalized:
+ return (
+ "Discount policy: discounts from 11 to 15 percent require regional sales director "
+ "approval. Discounts above 15 percent require both finance and the regional sales "
+ "director."
+ )
+ if "security" in normalized or "review" in normalized:
+ return (
+ "Security review policy: any new data export workflow must finish security review "
+ "before kickoff or production access."
+ )
+ return "No policy reference is available for that topic in this demo."
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/examples/sandbox/misc/workspace_apply_patch.py b/examples/sandbox/misc/workspace_apply_patch.py
new file mode 100644
index 00000000..acaec10c
--- /dev/null
+++ b/examples/sandbox/misc/workspace_apply_patch.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import io
+from pathlib import Path
+
+from agents import ApplyPatchTool, apply_diff
+from agents.editor import ApplyPatchOperation, ApplyPatchResult
+from agents.sandbox import Capability, Manifest
+from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
+from agents.tool import Tool
+
+
+def _read_text(handle: io.IOBase) -> str:
+ payload = handle.read()
+ if isinstance(payload, str):
+ return payload
+ if isinstance(payload, bytes | bytearray):
+ return bytes(payload).decode("utf-8", errors="replace")
+ return str(payload)
+
+
+class _SandboxWorkspaceEditor:
+ def __init__(self, session: BaseSandboxSession) -> None:
+ self._session = session
+
+ async def create_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
+ target = self._resolve_path(operation.path)
+ content = apply_diff("", operation.diff or "", mode="create")
+ await self._session.mkdir(target.parent, parents=True)
+ await self._session.write(target, io.BytesIO(content.encode("utf-8")))
+ return ApplyPatchResult(output=f"Created {self._display_path(target)}")
+
+ async def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
+ target = self._resolve_path(operation.path)
+ handle = await self._session.read(target)
+ try:
+ original = _read_text(handle)
+ finally:
+ handle.close()
+ updated = apply_diff(original, operation.diff or "")
+ await self._session.write(target, io.BytesIO(updated.encode("utf-8")))
+ return ApplyPatchResult(output=f"Updated {self._display_path(target)}")
+
+ async def delete_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
+ target = self._resolve_path(operation.path)
+ await self._session.rm(target)
+ return ApplyPatchResult(output=f"Deleted {self._display_path(target)}")
+
+ def _resolve_path(self, raw_path: str) -> Path:
+ return self._session.normalize_path(raw_path)
+
+ def _display_path(self, path: Path) -> str:
+ root = Path(self._session.state.manifest.root)
+ return path.relative_to(root).as_posix()
+
+
+class WorkspaceApplyPatchCapability(Capability):
+ """Expose the hosted apply_patch tool against the active sandbox workspace."""
+
+ def __init__(self) -> None:
+ super().__init__(type="workspace_apply_patch")
+ self._session: BaseSandboxSession | None = None
+
+ def bind(self, session: BaseSandboxSession) -> None:
+ self._session = session
+
+ def tools(self) -> list[Tool]:
+ if self._session is None:
+ return []
+ return [ApplyPatchTool(editor=_SandboxWorkspaceEditor(self._session))]
+
+ async def instructions(self, manifest: Manifest) -> str | None:
+ _ = manifest
+ return (
+ "Use the `apply_patch` tool for workspace text edits when you need to create or "
+ "update files inside the sandbox. Prefer saving final outputs in the requested "
+ "workspace directories instead of describing edits without writing them."
+ )
diff --git a/examples/sandbox/misc/workspace_shell.py b/examples/sandbox/misc/workspace_shell.py
new file mode 100644
index 00000000..766167a5
--- /dev/null
+++ b/examples/sandbox/misc/workspace_shell.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from agents.sandbox import Capability, Manifest
+from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
+from agents.tool import (
+ ShellCallOutcome,
+ ShellCommandOutput,
+ ShellCommandRequest,
+ ShellResult,
+ ShellTool,
+ Tool,
+)
+
+
+class WorkspaceShellCapability(Capability):
+ """Expose one shell tool for inspecting the active sandbox workspace."""
+
+ def __init__(self) -> None:
+ super().__init__(type="workspace_shell")
+ self._session: BaseSandboxSession | None = None
+
+ def bind(self, session: BaseSandboxSession) -> None:
+ self._session = session
+
+ def tools(self) -> list[Tool]:
+ return [ShellTool(executor=self._execute_shell)]
+
+ async def instructions(self, manifest: Manifest) -> str | None:
+ _ = manifest
+ return (
+ "Use the `shell` tool to inspect the sandbox workspace before answering. "
+ "The workspace root is the current working directory, so prefer relative paths "
+ "with commands like `pwd`, `find .`, and `cat`. Only cite files you actually read."
+ )
+
+ async def _execute_shell(self, request: ShellCommandRequest) -> ShellResult:
+ if self._session is None:
+ raise RuntimeError("Workspace shell is not bound to a sandbox session.")
+
+ timeout_s = (
+ request.data.action.timeout_ms / 1000
+ if request.data.action.timeout_ms is not None
+ else None
+ )
+ outputs: list[ShellCommandOutput] = []
+ for command in request.data.action.commands:
+ result = await self._session.exec(command, timeout=timeout_s, shell=True)
+ outputs.append(
+ ShellCommandOutput(
+ command=command,
+ stdout=result.stdout.decode("utf-8", errors="replace"),
+ stderr=result.stderr.decode("utf-8", errors="replace"),
+ outcome=ShellCallOutcome(type="exit", exit_code=result.exit_code),
+ )
+ )
+ return ShellResult(output=outputs)
diff --git a/examples/sandbox/sandbox_agent_capabilities.py b/examples/sandbox/sandbox_agent_capabilities.py
new file mode 100644
index 00000000..1625b958
--- /dev/null
+++ b/examples/sandbox/sandbox_agent_capabilities.py
@@ -0,0 +1,468 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import sys
+import tempfile
+from collections.abc import AsyncIterator
+from pathlib import Path
+from typing import Any, cast
+
+from openai.types.responses import ResponseFunctionCallArgumentsDeltaEvent, ResponseTextDeltaEvent
+from openai.types.responses.response_prompt_param import ResponsePromptParam
+
+from agents import (
+ AgentOutputSchemaBase,
+ AgentUpdatedStreamEvent,
+ ApplyPatchOperation,
+ Handoff,
+ ItemHelpers,
+ Model,
+ ModelResponse,
+ ModelSettings,
+ ModelTracing,
+ OpenAIProvider,
+ RawResponsesStreamEvent,
+ RunContextWrapper,
+ RunItemStreamEvent,
+ Runner,
+ RunResultStreaming,
+ Tool,
+ ToolOutputImage,
+)
+from agents.items import (
+ ToolCallItem,
+ ToolCallOutputItem,
+ TResponseInputItem,
+ TResponseStreamEvent,
+)
+from agents.run import RunConfig
+from agents.sandbox import LocalFile, Manifest, SandboxAgent, SandboxRunConfig
+from agents.sandbox.capabilities import (
+ Filesystem,
+ FilesystemToolSet,
+ LocalDirLazySkillSource,
+ Skills,
+)
+from agents.sandbox.capabilities.capabilities import Capabilities
+from agents.sandbox.entries import File, LocalDir
+from agents.sandbox.errors import WorkspaceReadNotFoundError
+from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
+from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+
+DEFAULT_MODEL = "gpt-5.4"
+COMPACTION_THRESHOLD = 1_000
+VERIFICATION_FILE = Path("verification/capabilities.txt")
+DELETE_FILE = Path("verification/delete-me.txt")
+
+
+class RecordingModel(Model):
+ def __init__(self, model_name: str) -> None:
+ self._model = OpenAIProvider().get_model(model_name)
+ self.first_input: str | list[TResponseInputItem] | None = None
+ self.first_model_settings: ModelSettings | None = None
+
+ 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:
+ if self.first_input is None:
+ self.first_input = input
+ self.first_model_settings = model_settings
+ return await self._model.get_response(
+ system_instructions,
+ input,
+ model_settings,
+ tools,
+ output_schema,
+ handoffs,
+ tracing,
+ previous_response_id=previous_response_id,
+ conversation_id=conversation_id,
+ prompt=prompt,
+ )
+
+ 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]:
+ if self.first_input is None:
+ self.first_input = input
+ self.first_model_settings = model_settings
+ return self._model.stream_response(
+ system_instructions,
+ input,
+ model_settings,
+ tools,
+ output_schema,
+ handoffs,
+ tracing,
+ previous_response_id=previous_response_id,
+ conversation_id=conversation_id,
+ prompt=prompt,
+ )
+
+ async def close(self) -> None:
+ await self._model.close()
+
+
+def _build_manifest() -> Manifest:
+ return Manifest(
+ entries={
+ "README.md": File(
+ content=(
+ b"# Capability Smoke Workspace\n\n"
+ b"This workspace is used to verify sandbox capabilities end to end.\n"
+ b"Project code name: atlas.\n"
+ )
+ ),
+ "notes/input.txt": File(content=b"source=filesystem\n"),
+ "examples/image.png": LocalFile(
+ src=Path(__file__).parent.parent.parent / "docs/assets/images/graph.png"
+ ),
+ }
+ )
+
+
+def _write_local_skill(skills_root: Path) -> None:
+ skill_dir = skills_root / "capability-proof"
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ (skill_dir / "SKILL.md").write_text(
+ "\n".join(
+ [
+ "---",
+ "name: capability-proof",
+ "description: Verifies the sandbox skills capability in the smoke example.",
+ "---",
+ "",
+ "# Capability Proof",
+ "",
+ "When loaded, write a verification file containing these exact lines:",
+ "- skill_loaded=true",
+ "- codename=atlas",
+ "- note_source=filesystem",
+ "",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+
+def _build_agent(model: RecordingModel, skills_root: Path) -> SandboxAgent:
+ capabilities = Capabilities.default() + [
+ Skills(lazy_from=LocalDirLazySkillSource(source=LocalDir(src=skills_root))),
+ ]
+
+ def apply_patch_needs_approval(
+ ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, call_id: str
+ ):
+ return False
+
+ def _configure_filesystem(toolset: FilesystemToolSet):
+ toolset.apply_patch.needs_approval = apply_patch_needs_approval
+
+ for capability in capabilities:
+ if isinstance(capability, Filesystem):
+ capability.configure_tools = _configure_filesystem
+
+ return SandboxAgent(
+ name="Sandbox Capabilities Smoke",
+ model=model,
+ instructions=(
+ "Run the sandbox capability smoke test end to end, use the available tools "
+ "deliberately, and then give a one-line final summary. "
+ "Follow this sequence:\n"
+ "1. Inspect the workspace root at `.`.\n"
+ "2. Read `README.md`.\n"
+ "3. Use `view_image` on `examples/image.png` and confirm it shows a routing diagram "
+ "centered on `Triage Agent`.\n"
+ "4. Use the `capability-proof` skill.\n"
+ f"5. Create `{VERIFICATION_FILE.as_posix()}` with exactly these two lines:\n"
+ " skill_loaded=true\n"
+ " codename=atlas\n"
+ "6. Update that file so it has exactly these four lines:\n"
+ " skill_loaded=true\n"
+ " codename=atlas\n"
+ " note_source=filesystem\n"
+ " image_verified=true\n"
+ f"7. Create `{DELETE_FILE.as_posix()}`, then delete it.\n"
+ f"8. Print `{VERIFICATION_FILE.as_posix()}` from the shell.\n"
+ "When referring to the workspace root in any path argument, use `.` exactly. Do not "
+ "use an empty string for a path.\n"
+ "Keep the final answer to one line: `capability smoke complete`."
+ ),
+ default_manifest=_build_manifest(),
+ capabilities=capabilities,
+ model_settings=ModelSettings(tool_choice="required"),
+ )
+
+
+def _initial_input() -> list[TResponseInputItem]:
+ return [
+ {
+ "role": "user",
+ "content": (
+ "Run the sandbox capability smoke test now. Use the listed tools and then answer "
+ "with `capability smoke complete`."
+ ),
+ },
+ ]
+
+
+def _tool_call_name(item: ToolCallItem) -> str:
+ raw_item = item.raw_item
+ if isinstance(raw_item, dict):
+ if raw_item.get("type") == "apply_patch_call":
+ return "apply_patch"
+ return cast(str, raw_item.get("name") or raw_item.get("type") or "")
+ return cast(str, getattr(raw_item, "name", None) or getattr(raw_item, "type", None) or "")
+
+
+async def _read_workspace_text(session: BaseSandboxSession, 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")
+
+
+def _format_tool_call_arguments(item: ToolCallItem) -> str | None:
+ 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 None
+
+ try:
+ parsed = json.loads(arguments)
+ except json.JSONDecodeError:
+ return arguments
+ return json.dumps(parsed, indent=2, sort_keys=True)
+
+
+def _format_tool_output(output: object) -> str:
+ text = str(output)
+ if len(text) <= 240:
+ return text
+ return f"{text[:240]}..."
+
+
+async def _print_stream_details(result: RunResultStreaming) -> None:
+ print("=== Stream starting ===")
+ print("Streaming raw text deltas, tool activity, and semantic run events as they arrive.\n")
+
+ active_tool_call: str | None = None
+ text_stream_open = False
+
+ async for event in result.stream_events():
+ if isinstance(event, AgentUpdatedStreamEvent):
+ if text_stream_open:
+ print()
+ text_stream_open = False
+ print(f"[agent] switched to: {event.new_agent.name}")
+ continue
+
+ if isinstance(event, RawResponsesStreamEvent):
+ data = event.data
+ if isinstance(data, ResponseTextDeltaEvent):
+ if not text_stream_open:
+ print("[model:text] ", end="", flush=True)
+ text_stream_open = True
+ print(data.delta, end="", flush=True)
+ continue
+ if isinstance(data, ResponseFunctionCallArgumentsDeltaEvent):
+ if text_stream_open:
+ print()
+ text_stream_open = False
+ if active_tool_call is None:
+ active_tool_call = "tool"
+ print("[model:tool_args] ", end="", flush=True)
+ print(data.delta, end="", flush=True)
+ continue
+
+ event_type = getattr(data, "type", None)
+ if event_type == "response.output_item.done" and active_tool_call is not None:
+ print()
+ print(f"[model:tool_args] completed for {active_tool_call}")
+ active_tool_call = None
+ continue
+
+ if text_stream_open:
+ print()
+ text_stream_open = False
+ if active_tool_call is not None:
+ print()
+ active_tool_call = None
+
+ if not isinstance(event, RunItemStreamEvent):
+ continue
+
+ if event.item.type == "tool_call_item":
+ tool_name = _tool_call_name(event.item)
+ active_tool_call = tool_name
+ print(f"[tool:call] {tool_name}")
+ arguments = _format_tool_call_arguments(event.item)
+ if arguments:
+ print(arguments)
+ elif event.item.type == "tool_call_output_item":
+ print(f"[tool:output] {_format_tool_output(event.item.output)}")
+ elif event.item.type == "message_output_item":
+ message_text = ItemHelpers.text_message_output(event.item)
+ print(f"[message:complete] {len(message_text)} characters")
+ elif event.item.type == "reasoning_item":
+ print("[reasoning] model emitted a reasoning item")
+ else:
+ print(f"[event:{event.name}] item_type={event.item.type}")
+
+ if text_stream_open:
+ print()
+ print("\n=== Stream complete ===")
+
+
+async def main(model_name: str) -> None:
+ model = RecordingModel(model_name)
+ with tempfile.TemporaryDirectory(prefix="agents-skills-") as temp_dir:
+ skills_root = Path(temp_dir) / "skills"
+ _write_local_skill(skills_root)
+
+ agent = _build_agent(model, skills_root)
+ client = UnixLocalSandboxClient()
+ sandbox = await client.create(manifest=agent.default_manifest)
+
+ try:
+ async with sandbox:
+ result = Runner.run_streamed(
+ agent,
+ _initial_input(),
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ tracing_disabled=True,
+ workflow_name="Sandbox capabilities smoke",
+ ),
+ )
+ await _print_stream_details(result)
+
+ tool_calls = [
+ _tool_call_name(item)
+ for item in result.new_items
+ if isinstance(item, ToolCallItem)
+ ]
+ tool_outputs = [
+ item.output for item in result.new_items if isinstance(item, ToolCallOutputItem)
+ ]
+ vision_outputs = [
+ output for output in tool_outputs if isinstance(output, ToolOutputImage)
+ ]
+ verification_text = await _read_workspace_text(sandbox, VERIFICATION_FILE)
+ delete_file_exists = True
+ try:
+ handle = await sandbox.read(DELETE_FILE)
+ except WorkspaceReadNotFoundError:
+ delete_file_exists = False
+ else:
+ handle.close()
+
+ first_model_settings = model.first_model_settings
+ if first_model_settings is None:
+ raise RuntimeError("Model settings were not captured")
+ extra_args = first_model_settings.extra_args or {}
+ if extra_args.get("context_management") is None:
+ raise RuntimeError(
+ f"Compaction sampling params were not attached: {extra_args!r}"
+ )
+
+ expected_tools = {
+ "load_skill",
+ "apply_patch",
+ "exec_command",
+ "view_image",
+ }
+ missing_tools = expected_tools - set(tool_calls)
+ if missing_tools:
+ raise RuntimeError(
+ "Missing expected tool calls: "
+ f"{sorted(missing_tools)}; observed tool calls: {tool_calls}"
+ )
+
+ expected_verification = (
+ "skill_loaded=true\n"
+ "codename=atlas\n"
+ "note_source=filesystem\n"
+ "image_verified=true\n"
+ )
+ if verification_text.rstrip("\n") != expected_verification.rstrip("\n"):
+ raise RuntimeError(
+ "Verification file content mismatch:\n"
+ f"expected={expected_verification!r}\n"
+ f"actual={verification_text!r}"
+ )
+
+ if expected_verification.strip() not in "\n".join(
+ str(output) for output in tool_outputs
+ ):
+ raise RuntimeError("Shell output did not include the verification file content")
+
+ if not vision_outputs:
+ raise RuntimeError("Expected view_image to produce a ToolOutputImage")
+
+ if not all(
+ isinstance(output.image_url, str) and output.image_url.startswith("data:image/")
+ for output in vision_outputs
+ ):
+ raise RuntimeError(
+ f"Expected ToolOutputImage data URLs from view_image, got {vision_outputs!r}"
+ )
+
+ if delete_file_exists:
+ raise RuntimeError(f"Expected {DELETE_FILE.as_posix()} to be deleted")
+
+ print("=== Final summary ===")
+ print("final_output:", result.final_output)
+ print("tool_calls:", ", ".join(tool_calls))
+ print("vision_outputs:", len(vision_outputs))
+ print(f"compaction_threshold: {COMPACTION_THRESHOLD}")
+ print(f"compaction_extra_args: {extra_args}")
+ print(f"verification_file: {VERIFICATION_FILE.as_posix()}")
+ print(f"deleted_file_absent: {not delete_file_exists}")
+ print(verification_text, end="")
+ finally:
+ await client.delete(sandbox)
+ await model.close()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
+ args = parser.parse_args()
+
+ asyncio.run(main(args.model))
diff --git a/examples/sandbox/sandbox_agent_with_remote_snapshot.py b/examples/sandbox/sandbox_agent_with_remote_snapshot.py
new file mode 100644
index 00000000..95f65158
--- /dev/null
+++ b/examples/sandbox/sandbox_agent_with_remote_snapshot.py
@@ -0,0 +1,173 @@
+"""
+Sandbox agent example using a dependency-injected remote snapshot client.
+
+This demonstrates persisting a Unix-local sandbox workspace to S3 with `RemoteSnapshotSpec`,
+then resuming the session from the downloaded snapshot.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import io
+import os
+import sys
+from pathlib import Path
+
+from agents import ModelSettings, Runner
+from agents.run import RunConfig
+from agents.sandbox import Manifest, RemoteSnapshotSpec, SandboxAgent, SandboxRunConfig
+from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
+from agents.sandbox.session import Dependencies
+
+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
+
+S3_BUCKET_ENV_VAR = "S3_MOUNT_BUCKET"
+SNAPSHOT_OBJECT_PREFIX = "openai-agents-python/sandbox-snapshots"
+SNAPSHOT_CLIENT_DEPENDENCY_KEY = "examples.remote_snapshot.s3_client"
+SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
+SNAPSHOT_CHECK_CONTENT = "remote snapshot round-trip ok\n"
+
+
+class S3SnapshotClient:
+ """Minimal S3 client adapter for `RemoteSnapshot`."""
+
+ def __init__(self, *, bucket: str, prefix: str) -> None:
+ try:
+ import boto3 # type: ignore[import-untyped]
+ except Exception as exc: # pragma: no cover - optional local dependency
+ raise SystemExit(
+ "This example requires boto3 for S3 snapshot storage.\n"
+ "Install it with: uv sync --extra s3"
+ ) from exc
+
+ self._bucket = bucket
+ self._prefix = prefix.rstrip("/")
+ self._s3 = boto3.client("s3")
+
+ def upload(self, snapshot_id: str, data: io.IOBase) -> None:
+ self._s3.upload_fileobj(data, self._bucket, self._object_key(snapshot_id))
+
+ def download(self, snapshot_id: str) -> io.IOBase:
+ buffer = io.BytesIO()
+ self._s3.download_fileobj(self._bucket, self._object_key(snapshot_id), buffer)
+ buffer.seek(0)
+ return buffer
+
+ def exists(self, snapshot_id: str) -> bool:
+ from botocore.exceptions import ClientError # type: ignore[import-untyped]
+
+ try:
+ self._s3.head_object(Bucket=self._bucket, Key=self._object_key(snapshot_id))
+ except ClientError as exc:
+ if exc.response.get("Error", {}).get("Code") in {"404", "NoSuchKey", "NotFound"}:
+ return False
+ raise
+ return True
+
+ def _object_key(self, snapshot_id: str) -> str:
+ return f"{self._prefix}/{snapshot_id}.tar"
+
+
+def _build_manifest() -> Manifest:
+ return text_manifest(
+ {
+ "README.md": (
+ "# Remote Snapshot Demo\n\n"
+ "This workspace exists to show a sandbox session persisting its snapshot to S3.\n"
+ ),
+ "status.md": (
+ "# Status\n\n"
+ "- The first run writes a snapshot check file into the workspace.\n"
+ "- The resumed run verifies that the file came back from remote storage.\n"
+ ),
+ }
+ )
+
+
+def _build_agent(*, model: str, manifest: Manifest) -> SandboxAgent:
+ return SandboxAgent(
+ name="Remote Snapshot Assistant",
+ model=model,
+ instructions=(
+ "Inspect the sandbox workspace before answering. Keep the response concise and "
+ "mention the file names you used. "
+ "Do not invent files or state. Only describe what is present in the workspace."
+ ),
+ default_manifest=manifest,
+ capabilities=[WorkspaceShellCapability()],
+ model_settings=ModelSettings(tool_choice="required"),
+ )
+
+
+def _require_s3_bucket() -> str:
+ bucket = os.environ.get(S3_BUCKET_ENV_VAR)
+ if not bucket:
+ raise SystemExit(f"{S3_BUCKET_ENV_VAR} must be set before running this example.")
+ return bucket
+
+
+async def _verify_remote_snapshot_round_trip(*, model: str) -> None:
+ manifest = _build_manifest()
+ dependencies = Dependencies().bind_value(
+ SNAPSHOT_CLIENT_DEPENDENCY_KEY,
+ S3SnapshotClient(bucket=_require_s3_bucket(), prefix=SNAPSHOT_OBJECT_PREFIX),
+ )
+ client = UnixLocalSandboxClient(dependencies=dependencies)
+
+ sandbox = await client.create(
+ manifest=manifest,
+ snapshot=RemoteSnapshotSpec(client_dependency_key=SNAPSHOT_CLIENT_DEPENDENCY_KEY),
+ options=None,
+ )
+
+ 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(
+ "Remote snapshot resume verification failed: "
+ f"expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}"
+ )
+ finally:
+ await resumed_sandbox.aclose()
+
+ agent = _build_agent(model=model, manifest=manifest)
+ result = await Runner.run(
+ agent,
+ "Summarize this workspace in one sentence.",
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(client=client),
+ workflow_name="Remote snapshot sandbox example",
+ ),
+ )
+
+ print("snapshot round-trip ok (s3)")
+ print(result.final_output)
+
+
+async def main(model: str) -> None:
+ await _verify_remote_snapshot_round_trip(model=model)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
+ args = parser.parse_args()
+
+ asyncio.run(main(args.model))
diff --git a/examples/sandbox/sandbox_agent_with_tools.py b/examples/sandbox/sandbox_agent_with_tools.py
new file mode 100644
index 00000000..a9dceb83
--- /dev/null
+++ b/examples/sandbox/sandbox_agent_with_tools.py
@@ -0,0 +1,116 @@
+"""
+Show how a sandbox agent can combine three tool sources in one run.
+
+This example gives the model:
+
+1. A sandbox workspace to inspect with the shared shell capability.
+2. A normal local function tool for approval routing.
+3. A local stdio MCP server for reference policy lookups.
+"""
+
+import argparse
+import asyncio
+import sys
+from pathlib import Path
+
+from agents import Runner, function_tool
+from agents.mcp import MCPServerStdio
+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, tool_call_name
+from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
+
+DEFAULT_QUESTION = (
+ "Review this enterprise renewal request. Tell me who needs to approve the discount, "
+ "whether security review is still open, and the most important note for the account team. "
+ "Confirm the approval and security answers against the reference policy server before you respond."
+)
+
+
+@function_tool
+def get_discount_approval_path(discount_percent: int) -> str:
+ """Return the approver required for a proposed discount percentage."""
+ if discount_percent <= 10:
+ return "The account executive can approve discounts up to 10 percent."
+ if discount_percent <= 15:
+ return "The regional sales director must approve discounts from 11 to 15 percent."
+ return "Finance and the regional sales director must both approve discounts above 15 percent."
+
+
+async def main(model: str, question: str) -> None:
+ # This manifest becomes the workspace that the sandbox agent can inspect.
+ manifest = text_manifest(
+ {
+ "renewal_request.md": (
+ "# Renewal request\n\n"
+ "- Customer: Contoso Manufacturing.\n"
+ "- Requested discount: 14 percent.\n"
+ "- Renewal term: 12 months.\n"
+ "- Requested close date: March 28.\n"
+ ),
+ "account_notes.md": (
+ "# Account notes\n\n"
+ "- The customer expanded usage in two plants this quarter.\n"
+ "- Security review for the new data export workflow was opened last week.\n"
+ "- Procurement wants a final approval map before they send the order form.\n"
+ ),
+ }
+ )
+
+ # The reference MCP server is another local process. The agent can call its tools alongside
+ # the sandbox shell tool and the normal Python function tool.
+ async with MCPServerStdio(
+ name="Reference Policy Server",
+ params={
+ "command": sys.executable,
+ "args": [
+ str(Path(__file__).resolve().parent / "misc" / "reference_policy_mcp_server.py")
+ ],
+ },
+ ) as server:
+ agent = SandboxAgent(
+ name="Renewal Review Assistant",
+ model=model,
+ instructions=(
+ "You review renewal requests. Inspect the packet, use "
+ "`get_discount_approval_path` for discount routing, and use the MCP reference "
+ "policy server when you need confirmation. Before you answer, you must call "
+ "`get_discount_approval_path` and at least one MCP policy tool. "
+ "Keep the answer concise and business-ready. Mention which policy topic you "
+ "confirmed through MCP."
+ ),
+ default_manifest=manifest,
+ tools=[get_discount_approval_path],
+ mcp_servers=[server],
+ capabilities=[WorkspaceShellCapability()],
+ )
+
+ result = await Runner.run(
+ agent,
+ question,
+ run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())),
+ )
+ tool_names: list[str] = []
+ for item in result.new_items:
+ if getattr(item, "type", None) != "tool_call_item":
+ continue
+ name = tool_call_name(item.raw_item)
+ if name:
+ tool_names.append(name)
+ if tool_names:
+ print(f"[tools used] {', '.join(tool_names)}")
+ 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))
diff --git a/examples/sandbox/sandbox_agents_as_tools.py b/examples/sandbox/sandbox_agents_as_tools.py
new file mode 100644
index 00000000..777b4c82
--- /dev/null
+++ b/examples/sandbox/sandbox_agents_as_tools.py
@@ -0,0 +1,203 @@
+"""
+Show how sandbox agents can be exposed as tools to a normal orchestrator.
+
+Each sandbox reviewer gets its own isolated workspace. The outer orchestrator
+does not inspect files directly. It calls the reviewers as tools and combines
+their outputs with a normal Python function tool.
+"""
+
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from agents import Agent, ModelSettings, Runner, function_tool
+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, tool_call_name
+from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
+
+DEFAULT_QUESTION = (
+ "Review the Acme renewal materials and give me a short recommendation for the deal desk. "
+ "Include pricing risk, rollout risk, and the most important next step."
+)
+
+
+class PricingPacketReview(BaseModel):
+ requested_discount_percent: int = Field(
+ description="Exact requested discount percentage from pricing_summary.md."
+ )
+ requested_term_months: int = Field(
+ description="Exact requested renewal term in months from pricing_summary.md."
+ )
+ pricing_risk: Literal["low", "medium", "high"]
+ summary: str = Field(description="Short pricing risk summary grounded in the reviewed files.")
+ recommended_next_step: str = Field(
+ description="Most important commercial next step for the deal desk."
+ )
+ evidence_files: list[str] = Field(
+ description="File names that support the review.", min_length=1
+ )
+
+
+class RolloutRiskReview(BaseModel):
+ rollout_risk: Literal["low", "medium", "high"]
+ summary: str = Field(description="Short rollout risk summary grounded in the reviewed files.")
+ blockers: list[str] = Field(description="Concrete rollout blockers from the reviewed files.")
+ recommended_next_step: str = Field(
+ description="Most important delivery next step for the deal desk."
+ )
+ evidence_files: list[str] = Field(
+ description="File names that support the review.", min_length=1
+ )
+
+
+async def _structured_tool_output_extractor(result) -> str:
+ final_output = result.final_output
+ if isinstance(final_output, BaseModel):
+ return json.dumps(final_output.model_dump(mode="json"), sort_keys=True)
+ return str(final_output)
+
+
+@function_tool
+def get_discount_approval_rule(discount_percent: int) -> str:
+ """Return the internal approver required for a proposed discount."""
+ if discount_percent <= 10:
+ return "Discounts up to 10 percent can be approved by the account executive."
+ if discount_percent <= 15:
+ return "Discounts from 11 to 15 percent require regional sales director approval."
+ return "Discounts above 15 percent require finance and regional sales director approval."
+
+
+async def main(model: str, question: str) -> None:
+ # This manifest is visible only to the pricing reviewer.
+ pricing_manifest = text_manifest(
+ {
+ "pricing_summary.md": (
+ "# Pricing summary\n\n"
+ "- Current annual contract: $220,000.\n"
+ "- Requested renewal term: 24 months.\n"
+ "- Requested discount: 15 percent.\n"
+ "- Account executive target discount band: 8 to 10 percent.\n"
+ ),
+ "commercial_notes.md": (
+ "# Commercial notes\n\n"
+ "- The customer expanded from 120 to 170 paid seats in the last 6 months.\n"
+ "- Procurement asked for one final concession to close before quarter end.\n"
+ ),
+ }
+ )
+
+ # This separate manifest is visible only to the rollout reviewer.
+ rollout_manifest = text_manifest(
+ {
+ "rollout_plan.md": (
+ "# Rollout plan\n\n"
+ "- Customer wants a 30-day rollout for three new regional teams.\n"
+ "- Regional admins have not completed training yet.\n"
+ "- SSO migration is scheduled for the second week of the rollout.\n"
+ ),
+ "support_history.md": (
+ "# Support history\n\n"
+ "- Two high-priority onboarding tickets were closed in the last quarter.\n"
+ "- No open production incidents.\n"
+ "- Customer success manager asked for a phased launch if the contract closes.\n"
+ ),
+ }
+ )
+
+ pricing_agent = SandboxAgent(
+ name="Pricing Packet Reviewer",
+ model=model,
+ instructions=(
+ "You inspect renewal pricing documents and return a structured commercial review. "
+ "Inspect the files before answering and extract the exact requested discount percent "
+ "and renewal term from pricing_summary.md. "
+ "Use the shell tool before answering. requested_discount_percent must match the exact "
+ "integer in pricing_summary.md. requested_term_months must match the exact renewal "
+ "term from pricing_summary.md. Do not introduce any facts, incidents, or numbers that "
+ "are not present in pricing_summary.md or commercial_notes.md. evidence_files must "
+ "list only files you actually inspected."
+ ),
+ default_manifest=pricing_manifest,
+ capabilities=[WorkspaceShellCapability()],
+ model_settings=ModelSettings(tool_choice="required"),
+ output_type=PricingPacketReview,
+ )
+ rollout_agent = SandboxAgent(
+ name="Rollout Risk Reviewer",
+ model=model,
+ instructions=(
+ "You inspect rollout plans and return a structured delivery review. Inspect the files "
+ "before answering and keep the output tightly grounded in the rollout documents. "
+ "Use the shell tool before answering. blockers must only contain issues that appear in "
+ "rollout_plan.md or support_history.md. Do not introduce any extra numbers, incidents, "
+ "or stakeholders beyond those files. evidence_files must list only files you actually "
+ "inspected."
+ ),
+ default_manifest=rollout_manifest,
+ capabilities=[WorkspaceShellCapability()],
+ model_settings=ModelSettings(tool_choice="required"),
+ output_type=RolloutRiskReview,
+ )
+
+ # Each sandbox-backed tool gets its own run configuration so the workspaces stay isolated.
+ pricing_run_config = RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()))
+ rollout_run_config = RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()))
+
+ orchestrator = Agent(
+ name="Revenue Operations Coordinator",
+ model=model,
+ instructions=(
+ "You coordinate renewal reviews. Before answering, you must use all three tools: "
+ "`review_pricing_packet`, `review_rollout_risk`, and `get_discount_approval_rule`. "
+ "The review tools return JSON. Use the exact `requested_discount_percent` field from "
+ "`review_pricing_packet` when calling `get_discount_approval_rule`. In the final "
+ "recommendation, use only facts and numbers that appear in the tool outputs, and do "
+ "not add any extra incidents, price points, or contract terms."
+ ),
+ model_settings=ModelSettings(tool_choice="required"),
+ tools=[
+ pricing_agent.as_tool(
+ tool_name="review_pricing_packet",
+ tool_description="Inspect the pricing packet and summarize commercial risk.",
+ custom_output_extractor=_structured_tool_output_extractor,
+ run_config=pricing_run_config,
+ ),
+ rollout_agent.as_tool(
+ tool_name="review_rollout_risk",
+ tool_description="Inspect the rollout packet and summarize implementation risk.",
+ custom_output_extractor=_structured_tool_output_extractor,
+ run_config=rollout_run_config,
+ ),
+ get_discount_approval_rule,
+ ],
+ )
+
+ result = await Runner.run(orchestrator, question)
+ tool_names = [
+ tool_call_name(item.raw_item)
+ for item in result.new_items
+ if getattr(item, "type", None) == "tool_call_item"
+ ]
+ if tool_names:
+ print(f"[tools used] {', '.join(tool_names)}")
+ 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))
diff --git a/examples/sandbox/tax_prep.py b/examples/sandbox/tax_prep.py
new file mode 100644
index 00000000..6028913d
--- /dev/null
+++ b/examples/sandbox/tax_prep.py
@@ -0,0 +1,259 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+from pathlib import Path
+from typing import cast
+
+from openai.types.responses import ResponseTextDeltaEvent
+
+from agents import Runner
+from agents.items import TResponseInputItem
+from agents.run import RunConfig
+from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
+from agents.sandbox.capabilities import Capabilities, Skills
+from agents.sandbox.entries import Dir, GitRepo, LocalFile
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+
+DATA_PATH = Path(__file__).resolve().parent / "data"
+W2_PATH = DATA_PATH / "sample_w2.pdf"
+FORM_1040_PATH = DATA_PATH / "f1040.pdf"
+DEFAULT_IMAGE = "tax-prep:latest"
+DEFAULT_SKILLS_REPO = "sdcoffey/tax-prep-skills"
+DEFAULT_SKILLS_REF = "main"
+DEFAULT_QUESTION = "Please generate a 1040 for filing year 2025."
+
+INSTRUCTIONS = """
+You are a federal tax filing agent. Your job is to compute year-end taxes and
+produce a filled-out Form 1040 for the specified tax year using the user's
+provided documents. Use only the information in the supplied files. If required
+data is missing or unclear, ask follow-up questions or note explicit
+assumptions. Save the finalized, filled PDF in the `output/` directory and
+provide a short summary of key amounts such as income, deductions, tax, and
+refund or amount due.
+
+This is a demo, so assume the following unless the workspace says otherwise:
+1. Filing status is single.
+2. SSN is 123-45-6789.
+3. Date of birth is 1991-01-01.
+4. There are no other income documents.
+5. If a minor data point is still needed, make up a clearly synthetic test value.
+
+Use the `federal-tax-prep` skill to accomplish this task.
+""".strip()
+
+
+def _require_docker_dependency():
+ try:
+ 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(
+ "Docker-backed runs require the Docker SDK.\n"
+ "Install the repo dependencies with: make sync"
+ ) from exc
+
+ from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
+
+ return docker_from_env, DockerSandboxClient, DockerSandboxClientOptions
+
+
+def _build_manifest() -> Manifest:
+ return Manifest(
+ entries={
+ "taxpayer_data": Dir(
+ children={"sample_w2.pdf": LocalFile(src=W2_PATH)},
+ description="Taxpayer income documents such as W-2s and 1099s.",
+ ),
+ "reference_forms": Dir(
+ children={"f1040.pdf": LocalFile(src=FORM_1040_PATH)},
+ description="Blank tax forms the agent can use as templates.",
+ ),
+ "output": Dir(description="Write finalized tax documents here."),
+ }
+ )
+
+
+def _build_agent(*, model: str, skills_repo: str, skills_ref: str) -> SandboxAgent:
+ return SandboxAgent(
+ name="Tax Prep Assistant",
+ model=model,
+ instructions=(
+ INSTRUCTIONS + "\n\n"
+ "Inspect the workspace before answering. Keep final explanations concise, and make "
+ "sure the final filled files are actually written into `output/`."
+ ),
+ default_manifest=_build_manifest(),
+ capabilities=Capabilities.default()
+ + [
+ Skills(
+ from_=GitRepo(repo=skills_repo, ref=skills_ref),
+ ),
+ ],
+ )
+
+
+async def _copy_output_dir(
+ *,
+ session,
+ destination_root: Path,
+) -> list[Path]:
+ destination_root.mkdir(parents=True, exist_ok=True)
+ remote_output_root = session.normalize_path("output")
+
+ pending_dirs = [remote_output_root]
+ copied_files: list[Path] = []
+ while pending_dirs:
+ current_dir = pending_dirs.pop()
+ for entry in await session.ls(current_dir):
+ entry_path = Path(entry.path)
+ if entry.is_dir():
+ pending_dirs.append(entry_path)
+ continue
+
+ relative_path = entry_path.relative_to(remote_output_root)
+ local_path = destination_root / relative_path
+ local_path.parent.mkdir(parents=True, exist_ok=True)
+
+ handle = await session.read(entry_path)
+ try:
+ payload = handle.read()
+ finally:
+ handle.close()
+
+ if isinstance(payload, str):
+ local_path.write_text(payload, encoding="utf-8")
+ else:
+ local_path.write_bytes(bytes(payload))
+ copied_files.append(local_path)
+
+ return copied_files
+
+
+async def _run_turn(
+ *,
+ agent: SandboxAgent,
+ input_items: list[TResponseInputItem],
+ run_config: RunConfig,
+) -> list[TResponseInputItem]:
+ stream_result = Runner.run_streamed(agent, input_items, 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)
+ continue
+
+ if event.type == "run_item_stream_event" and event.name == "tool_called":
+ raw_item = getattr(event.item, "raw_item", None)
+ tool_name = ""
+ if isinstance(raw_item, dict):
+ tool_name = cast(str, raw_item.get("name") or raw_item.get("type") or "")
+ else:
+ tool_name = cast(
+ str,
+ getattr(raw_item, "name", None) or getattr(raw_item, "type", None) or "",
+ )
+ if tool_name:
+ if saw_text_delta:
+ print()
+ saw_text_delta = False
+ print(f"[tool call] {tool_name}")
+
+ if saw_text_delta:
+ print()
+
+ return stream_result.to_input_list()
+
+
+async def main(
+ *,
+ model: str,
+ image: str,
+ question: str,
+ output_dir: Path,
+ skills_repo: str,
+ skills_ref: str,
+) -> None:
+ docker_from_env, DockerSandboxClient, DockerSandboxClientOptions = _require_docker_dependency()
+ agent = _build_agent(model=model, skills_repo=skills_repo, skills_ref=skills_ref)
+ client = DockerSandboxClient(docker_from_env())
+ sandbox = await client.create(
+ manifest=agent.default_manifest,
+ options=DockerSandboxClientOptions(image=image),
+ )
+
+ run_config = RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ workflow_name="Sandbox tax prep demo",
+ )
+
+ conversation: list[TResponseInputItem] = [{"role": "user", "content": question}]
+
+ try:
+ async with sandbox:
+ conversation = await _run_turn(
+ agent=agent,
+ input_items=conversation,
+ run_config=run_config,
+ )
+
+ while True:
+ try:
+ additional_input = input("> ")
+ except (EOFError, KeyboardInterrupt):
+ break
+
+ conversation.append({"role": "user", "content": additional_input})
+ conversation = await _run_turn(
+ agent=agent,
+ input_items=conversation,
+ run_config=run_config,
+ )
+
+ copied_files = await _copy_output_dir(session=sandbox, destination_root=output_dir)
+ finally:
+ await client.delete(sandbox)
+
+ print(f"\nCopied {len(copied_files)} file(s) to {output_dir}")
+ for copied_file in copied_files:
+ print(copied_file)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
+ parser.add_argument("--image", default=DEFAULT_IMAGE, help="Docker image for the sandbox.")
+ parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
+ parser.add_argument(
+ "--output-dir",
+ default="tax-prep-results",
+ help="Local directory where files from sandbox output/ will be copied.",
+ )
+ parser.add_argument(
+ "--skills-repo",
+ default=DEFAULT_SKILLS_REPO,
+ help="GitHub repo in owner/name form for the skills bundle.",
+ )
+ parser.add_argument(
+ "--skills-ref",
+ default=DEFAULT_SKILLS_REF,
+ help="Git ref for the skills bundle.",
+ )
+ args = parser.parse_args()
+
+ asyncio.run(
+ main(
+ model=args.model,
+ image=args.image,
+ question=args.question,
+ output_dir=Path(args.output_dir).resolve(),
+ skills_repo=args.skills_repo,
+ skills_ref=args.skills_ref,
+ )
+ )
diff --git a/examples/sandbox/tutorials/Dockerfile b/examples/sandbox/tutorials/Dockerfile
new file mode 100644
index 00000000..1c58f0ac
--- /dev/null
+++ b/examples/sandbox/tutorials/Dockerfile
@@ -0,0 +1,13 @@
+FROM python:3.14-slim
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ git \
+ poppler-utils \
+ ripgrep \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN python -m pip install --no-cache-dir pypdf uv
+
+WORKDIR /workspace
diff --git a/examples/sandbox/tutorials/__init__.py b/examples/sandbox/tutorials/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/examples/sandbox/tutorials/__init__.py
@@ -0,0 +1 @@
+
diff --git a/examples/sandbox/tutorials/data/dataroom/setup.py b/examples/sandbox/tutorials/data/dataroom/setup.py
new file mode 100755
index 00000000..91421bd8
--- /dev/null
+++ b/examples/sandbox/tutorials/data/dataroom/setup.py
@@ -0,0 +1,240 @@
+"""Generate the synthetic dataroom fixture files."""
+
+from pathlib import Path
+
+
+def pdf_escape(text: str) -> str:
+ return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
+
+
+def write_plain_pdf(path: Path, lines: list[str]) -> None:
+ content_lines = ["BT", "/F1 11 Tf", "50 760 Td", "14 TL"]
+ for index, line in enumerate(lines):
+ operator = "Tj" if index == 0 else "T* Tj"
+ content_lines.append(f"({pdf_escape(line)}) {operator}")
+ content_lines.append("ET")
+ stream = "\n".join(content_lines).encode("utf-8")
+
+ objects = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
+ b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
+ b"<< /Length "
+ + str(len(stream)).encode("ascii")
+ + b" >>\nstream\n"
+ + stream
+ + b"\nendstream",
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ ]
+
+ pdf = bytearray(b"%PDF-1.4\n")
+ offsets = [0]
+ for index, body in enumerate(objects, start=1):
+ offsets.append(len(pdf))
+ pdf.extend(f"{index} 0 obj\n".encode("ascii"))
+ pdf.extend(body)
+ pdf.extend(b"\nendobj\n")
+
+ xref_offset = len(pdf)
+ pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
+ pdf.extend(b"0000000000 65535 f \n")
+ for offset in offsets[1:]:
+ pdf.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
+ pdf.extend(
+ (
+ "trailer\n"
+ f"<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
+ "startxref\n"
+ f"{xref_offset}\n"
+ "%%EOF\n"
+ ).encode("ascii")
+ )
+ path.write_bytes(pdf)
+
+
+def write_financial_pdf(path: Path, title: str, lines: list[str], rows: list[list[str]]) -> None:
+ write_plain_pdf(path, [title, *lines, *(" | ".join(row) for row in rows)])
+
+
+def write_fixture_text(data_dir: Path, filename: str, content: str) -> None:
+ (data_dir / filename).write_text(content.strip() + "\n", encoding="utf-8")
+
+
+def main() -> None:
+ data_dir = Path(__file__).resolve().parent
+ write_fixture_text(
+ data_dir,
+ "10-k-mdna-overview.txt",
+ """
+UNITED STATES
+SECURITIES AND EXCHANGE COMMISSION
+Washington, D.C. 20549
+
+FORM 10-K
+ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
+For the fiscal year ended December 31, 2025
+
+HelioCart, Inc.
+
+PART II
+Item 7. Management's Discussion and Analysis of Financial Condition and Results of Operations
+
+Revenue for fiscal 2025 was $1,284 million, compared with $1,008 million in fiscal 2024.
+The increase was driven primarily by Platform revenue growth from merchant fraud
+decisioning and payment orchestration workloads.
+
+Gross margin improved to 71.4% in fiscal 2025 from 68.2% in fiscal 2024 because a higher
+mix of transaction volume ran on lower-cost model serving infrastructure.
+
+Operating income was $186 million in fiscal 2025, compared with $118 million in fiscal 2024.
+Management uses "net revenue" and "revenue" interchangeably in this MD&A section.
+""",
+ )
+ write_fixture_text(
+ data_dir,
+ "10-k-mdna-liquidity.txt",
+ """
+UNITED STATES
+SECURITIES AND EXCHANGE COMMISSION
+Washington, D.C. 20549
+
+FORM 10-K
+ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
+For the fiscal year ended December 31, 2025
+
+HelioCart, Inc.
+
+PART II
+Item 7. Management's Discussion and Analysis of Financial Condition and Results of Operations
+
+Liquidity and capital resources. Net cash provided by operating activities was $248 million
+in fiscal 2025, compared with $192 million in fiscal 2024, primarily because of higher
+cash collections and improved operating margins.
+
+Capital expenditures were $86 million in fiscal 2025 and $73 million in fiscal 2024.
+Free cash flow, a non-GAAP measure defined as operating cash flow less capital
+expenditures, was $162 million in fiscal 2025 and $119 million in fiscal 2024.
+""",
+ )
+ write_fixture_text(
+ data_dir,
+ "10-k-note-segments.txt",
+ """
+UNITED STATES
+SECURITIES AND EXCHANGE COMMISSION
+Washington, D.C. 20549
+
+FORM 10-K
+ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
+For the fiscal year ended December 31, 2025
+
+HelioCart, Inc.
+
+PART II
+Item 8. Financial Statements and Supplementary Data
+
+Note 4. Revenue by reportable segment
+
+Platform segment revenue was $942 million in fiscal 2025 and $711 million in fiscal 2024.
+Services segment revenue was $342 million in fiscal 2025 and $297 million in fiscal 2024.
+
+Management refers to Platform revenue as "Subscription and transaction platform revenue"
+in some tables; treat that label as the same Platform segment revenue metric.
+""",
+ )
+ write_fixture_text(
+ data_dir,
+ "10-k-note-geography.txt",
+ """
+UNITED STATES
+SECURITIES AND EXCHANGE COMMISSION
+Washington, D.C. 20549
+
+FORM 10-K
+ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
+For the fiscal year ended December 31, 2025
+
+HelioCart, Inc.
+
+PART II
+Item 8. Financial Statements and Supplementary Data
+
+Note 5. Revenue by geography
+
+Americas revenue was $764 million in fiscal 2025, EMEA revenue was $343 million,
+and APAC revenue was $177 million. Those regional line items reconcile to the
+company-wide revenue figure disclosed in MD&A.
+""",
+ )
+ write_fixture_text(
+ data_dir,
+ "10-k-note-balance-sheet.txt",
+ """
+UNITED STATES
+SECURITIES AND EXCHANGE COMMISSION
+Washington, D.C. 20549
+
+FORM 10-K
+ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
+For the fiscal year ended December 31, 2025
+
+HelioCart, Inc.
+
+PART II
+Item 8. Financial Statements and Supplementary Data
+
+Note 7. Selected balance sheet metrics
+
+Cash and cash equivalents were $422 million as of December 31, 2025, compared with
+$351 million as of December 31, 2024. Deferred revenue was $402 million as of
+December 31, 2025, compared with $337 million as of December 31, 2024.
+""",
+ )
+
+ write_financial_pdf(
+ data_dir / "10-k-statements-of-operations.pdf",
+ "Consolidated Statements of Operations",
+ [
+ "The table below presents annual operating results for fiscal 2025 and fiscal 2024.",
+ "Revenue and net revenue refer to the same top-line measure for this synthetic filing.",
+ ],
+ [
+ ["Metric", "FY2025", "FY2024"],
+ ["Net revenue", "1,284", "1,008"],
+ ["Gross profit", "917", "687"],
+ ["Operating income", "186", "118"],
+ ],
+ )
+ write_financial_pdf(
+ data_dir / "10-k-balance-sheets.pdf",
+ "Consolidated Balance Sheets",
+ [
+ "The table below presents selected balance sheet amounts as of December 31, 2025 and 2024.",
+ "Amounts are shown in USD millions.",
+ ],
+ [
+ ["Metric", "2025", "2024"],
+ ["Cash and cash equivalents", "422", "351"],
+ ["Accounts receivable", "211", "187"],
+ ["Deferred revenue", "402", "337"],
+ ],
+ )
+ write_financial_pdf(
+ data_dir / "10-k-statements-of-cash-flows.pdf",
+ "Consolidated Statements of Cash Flows",
+ [
+ "The table below presents selected annual cash flow metrics for fiscal 2025 and 2024.",
+ "Net cash provided by operating activities is also described as operating cash flow in MD&A.",
+ ],
+ [
+ ["Metric", "FY2025", "FY2024"],
+ ["Net cash provided by operating activities", "248", "192"],
+ ["Capital expenditures", "86", "73"],
+ ["Free cash flow", "162", "119"],
+ ],
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/sandbox/tutorials/dataroom_metric_extract/README.md b/examples/sandbox/tutorials/dataroom_metric_extract/README.md
new file mode 100644
index 00000000..6c9a5779
--- /dev/null
+++ b/examples/sandbox/tutorials/dataroom_metric_extract/README.md
@@ -0,0 +1,59 @@
+# Dataroom metric extract
+
+## Goal
+
+Extract financial metrics from a synthetic 10-K packet, write the resulting
+table as CSV or JSONL, then validate the generated artifact with a deterministic
+eval script.
+
+The packet uses synthetic company data, but the source docs are formatted as
+annual-report excerpts with 10-K `Part II, Item 7` MD&A sections and `Part II,
+Item 8` financial statement sections.
+
+## Why this is valuable
+
+This demo shows a single-pass structured extraction pattern: a sandbox agent
+reads messy filing documents and emits typed financial rows, then a separate
+host-side eval script checks the artifact. The wrapper does not repair or
+deduplicate model output after the fact; if the row set is wrong, `evals.py`
+fails and you iterate on the prompt or fixture data instead.
+
+## Setup
+
+Run the fixture generator and then the Unix-local example from the repository
+root. Set `OPENAI_API_KEY` in your shell environment before running the example.
+
+```bash
+uv run python examples/sandbox/tutorials/data/dataroom/setup.py
+uv run python examples/sandbox/tutorials/dataroom_metric_extract/main.py --output-format csv
+uv run python examples/sandbox/tutorials/dataroom_metric_extract/evals.py --artifact-path examples/sandbox/tutorials/dataroom_metric_extract/output/financial_metrics.csv
+```
+
+After the initial extraction, the demo keeps the sandbox session open for
+Rich-rendered follow-up prompts before writing the final artifact. Pass
+`--no-interactive` for a one-shot run.
+
+To run extraction in Docker, build the shared tutorial image once and add `--docker`
+to `main.py`:
+
+```bash
+docker build --tag sandbox-tutorials:latest examples/sandbox/tutorials
+uv run python examples/sandbox/tutorials/dataroom_metric_extract/main.py --docker --output-format csv
+uv run python examples/sandbox/tutorials/dataroom_metric_extract/evals.py --artifact-path examples/sandbox/tutorials/dataroom_metric_extract/output/financial_metrics.csv
+```
+
+## Expected artifacts
+
+- `output/financial_metrics.csv`
+- `output/financial_metrics.jsonl`
+
+## Demo shape
+
+- Inputs: the shared SEC fixture packet in `examples/sandbox/tutorials/data/dataroom/`.
+- Runtime primitives: sandbox-local bash/file search plus typed agent outputs.
+- Workflow: a fixed single-step pipeline where the sandbox extractor emits
+ `FinancialMetricBatch`; no handoff is needed. `main.py` writes the selected
+ artifact format, and `evals.py` validates that artifact in a separate step.
+- Scratch space: the extractor may use `scratchpad/` for interim notes, but only
+ the selected `output/financial_metrics.*` artifact is part of the final
+ contract.
diff --git a/examples/sandbox/tutorials/dataroom_metric_extract/__init__.py b/examples/sandbox/tutorials/dataroom_metric_extract/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/examples/sandbox/tutorials/dataroom_metric_extract/__init__.py
@@ -0,0 +1 @@
+
diff --git a/examples/sandbox/tutorials/dataroom_metric_extract/evals.py b/examples/sandbox/tutorials/dataroom_metric_extract/evals.py
new file mode 100644
index 00000000..1d3bc046
--- /dev/null
+++ b/examples/sandbox/tutorials/dataroom_metric_extract/evals.py
@@ -0,0 +1,315 @@
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING, TypeAlias
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+if TYPE_CHECKING or __package__:
+ from .schemas import FinancialMetric, FinancialMetricBatch
+else:
+ from schemas import FinancialMetric, FinancialMetricBatch
+
+MetricKey: TypeAlias = tuple[str, str, str, str | None]
+
+EXPECTED_SOURCE_METADATA: dict[str, str] = {
+ "data/10-k-mdna-overview.txt": (
+ "Part II, Item 7. Management's Discussion and Analysis of Financial Condition and "
+ "Results of Operations"
+ ),
+ "data/10-k-mdna-liquidity.txt": (
+ "Part II, Item 7. Management's Discussion and Analysis of Financial Condition and "
+ "Results of Operations"
+ ),
+ "data/10-k-note-segments.txt": ("Part II, Item 8. Financial Statements and Supplementary Data"),
+ "data/10-k-note-geography.txt": (
+ "Part II, Item 8. Financial Statements and Supplementary Data"
+ ),
+ "data/10-k-note-balance-sheet.txt": (
+ "Part II, Item 8. Financial Statements and Supplementary Data"
+ ),
+ "data/10-k-statements-of-operations.pdf": (
+ "Part II, Item 8. Financial Statements and Supplementary Data"
+ ),
+ "data/10-k-balance-sheets.pdf": (
+ "Part II, Item 8. Financial Statements and Supplementary Data"
+ ),
+ "data/10-k-statements-of-cash-flows.pdf": (
+ "Part II, Item 8. Financial Statements and Supplementary Data"
+ ),
+}
+
+EXPECTED_ROWS: dict[MetricKey, tuple[float, str]] = {
+ ("data/10-k-mdna-overview.txt", "Revenue", "FY2025", None): (1284.0, "USD millions"),
+ ("data/10-k-mdna-overview.txt", "Revenue", "FY2024", None): (1008.0, "USD millions"),
+ ("data/10-k-mdna-overview.txt", "Gross margin", "FY2025", None): (71.4, "percent"),
+ ("data/10-k-mdna-overview.txt", "Gross margin", "FY2024", None): (68.2, "percent"),
+ ("data/10-k-mdna-overview.txt", "Operating income", "FY2025", None): (186.0, "USD millions"),
+ ("data/10-k-mdna-overview.txt", "Operating income", "FY2024", None): (118.0, "USD millions"),
+ (
+ "data/10-k-mdna-liquidity.txt",
+ "Net cash provided by operating activities",
+ "FY2025",
+ None,
+ ): (248.0, "USD millions"),
+ (
+ "data/10-k-mdna-liquidity.txt",
+ "Net cash provided by operating activities",
+ "FY2024",
+ None,
+ ): (192.0, "USD millions"),
+ ("data/10-k-mdna-liquidity.txt", "Capital expenditures", "FY2025", None): (
+ 86.0,
+ "USD millions",
+ ),
+ ("data/10-k-mdna-liquidity.txt", "Capital expenditures", "FY2024", None): (
+ 73.0,
+ "USD millions",
+ ),
+ ("data/10-k-mdna-liquidity.txt", "Free cash flow", "FY2025", None): (
+ 162.0,
+ "USD millions",
+ ),
+ ("data/10-k-mdna-liquidity.txt", "Free cash flow", "FY2024", None): (
+ 119.0,
+ "USD millions",
+ ),
+ ("data/10-k-note-segments.txt", "Platform segment revenue", "FY2025", "Platform"): (
+ 942.0,
+ "USD millions",
+ ),
+ ("data/10-k-note-segments.txt", "Platform segment revenue", "FY2024", "Platform"): (
+ 711.0,
+ "USD millions",
+ ),
+ ("data/10-k-note-segments.txt", "Services segment revenue", "FY2025", "Services"): (
+ 342.0,
+ "USD millions",
+ ),
+ ("data/10-k-note-segments.txt", "Services segment revenue", "FY2024", "Services"): (
+ 297.0,
+ "USD millions",
+ ),
+ ("data/10-k-note-geography.txt", "Americas revenue", "FY2025", "Americas"): (
+ 764.0,
+ "USD millions",
+ ),
+ ("data/10-k-note-geography.txt", "EMEA revenue", "FY2025", "EMEA"): (
+ 343.0,
+ "USD millions",
+ ),
+ ("data/10-k-note-geography.txt", "APAC revenue", "FY2025", "APAC"): (
+ 177.0,
+ "USD millions",
+ ),
+ (
+ "data/10-k-note-balance-sheet.txt",
+ "Cash and cash equivalents",
+ "2025-12-31",
+ None,
+ ): (422.0, "USD millions"),
+ (
+ "data/10-k-note-balance-sheet.txt",
+ "Cash and cash equivalents",
+ "2024-12-31",
+ None,
+ ): (351.0, "USD millions"),
+ ("data/10-k-note-balance-sheet.txt", "Deferred revenue", "2025-12-31", None): (
+ 402.0,
+ "USD millions",
+ ),
+ ("data/10-k-note-balance-sheet.txt", "Deferred revenue", "2024-12-31", None): (
+ 337.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-operations.pdf", "Net revenue", "FY2025", None): (
+ 1284.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-operations.pdf", "Net revenue", "FY2024", None): (
+ 1008.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-operations.pdf", "Gross profit", "FY2025", None): (
+ 917.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-operations.pdf", "Gross profit", "FY2024", None): (
+ 687.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-operations.pdf", "Operating income", "FY2025", None): (
+ 186.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-operations.pdf", "Operating income", "FY2024", None): (
+ 118.0,
+ "USD millions",
+ ),
+ (
+ "data/10-k-balance-sheets.pdf",
+ "Cash and cash equivalents",
+ "2025-12-31",
+ None,
+ ): (422.0, "USD millions"),
+ (
+ "data/10-k-balance-sheets.pdf",
+ "Cash and cash equivalents",
+ "2024-12-31",
+ None,
+ ): (351.0, "USD millions"),
+ ("data/10-k-balance-sheets.pdf", "Accounts receivable", "2025-12-31", None): (
+ 211.0,
+ "USD millions",
+ ),
+ ("data/10-k-balance-sheets.pdf", "Accounts receivable", "2024-12-31", None): (
+ 187.0,
+ "USD millions",
+ ),
+ ("data/10-k-balance-sheets.pdf", "Deferred revenue", "2025-12-31", None): (
+ 402.0,
+ "USD millions",
+ ),
+ ("data/10-k-balance-sheets.pdf", "Deferred revenue", "2024-12-31", None): (
+ 337.0,
+ "USD millions",
+ ),
+ (
+ "data/10-k-statements-of-cash-flows.pdf",
+ "Net cash provided by operating activities",
+ "FY2025",
+ None,
+ ): (248.0, "USD millions"),
+ (
+ "data/10-k-statements-of-cash-flows.pdf",
+ "Net cash provided by operating activities",
+ "FY2024",
+ None,
+ ): (192.0, "USD millions"),
+ ("data/10-k-statements-of-cash-flows.pdf", "Capital expenditures", "FY2025", None): (
+ 86.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-cash-flows.pdf", "Capital expenditures", "FY2024", None): (
+ 73.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-cash-flows.pdf", "Free cash flow", "FY2025", None): (
+ 162.0,
+ "USD millions",
+ ),
+ ("data/10-k-statements-of-cash-flows.pdf", "Free cash flow", "FY2024", None): (
+ 119.0,
+ "USD millions",
+ ),
+}
+
+
+@dataclass(frozen=True)
+class EvalSummary:
+ row_count: int
+
+
+def load_metrics(artifact_path: Path) -> FinancialMetricBatch:
+ if artifact_path.suffix == ".jsonl":
+ metrics = [
+ FinancialMetric.model_validate_json(line)
+ for line in artifact_path.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+ return FinancialMetricBatch(metrics=metrics)
+
+ if artifact_path.suffix == ".csv":
+ with artifact_path.open(encoding="utf-8", newline="") as input_file:
+ reader = csv.DictReader(input_file)
+ metrics = []
+ for row in reader:
+ row["segment"] = row["segment"] or None
+ row["value"] = float(row["value"])
+ metrics.append(FinancialMetric.model_validate(row))
+ return FinancialMetricBatch(metrics=metrics)
+
+ raise ValueError(f"Unsupported artifact type: {artifact_path}")
+
+
+def validate_outputs(metrics: FinancialMetricBatch) -> EvalSummary:
+ rows = metrics.metrics
+ duplicate_keys: list[MetricKey] = []
+ seen_keys: set[MetricKey] = set()
+ rows_by_key: dict[MetricKey, FinancialMetric] = {
+ (
+ row.source_file.strip(),
+ row.metric_name.strip(),
+ row.fiscal_period,
+ row.segment.strip() if row.segment else None,
+ ): row
+ for row in rows
+ }
+
+ for row in rows:
+ row_key = (
+ row.source_file.strip(),
+ row.metric_name.strip(),
+ row.fiscal_period,
+ row.segment.strip() if row.segment else None,
+ )
+ if row_key in seen_keys:
+ duplicate_keys.append(row_key)
+ seen_keys.add(row_key)
+
+ if duplicate_keys:
+ raise AssertionError(f"Duplicate metric rows found: {sorted(set(duplicate_keys))}.")
+
+ if len(rows) != len(EXPECTED_ROWS):
+ raise AssertionError(
+ f"Expected exactly {len(EXPECTED_ROWS)} metric rows, found {len(rows)}."
+ )
+
+ for source_file, expected_section in EXPECTED_SOURCE_METADATA.items():
+ source_rows = [row for row in rows if row.source_file.strip() == source_file]
+ if not source_rows:
+ raise AssertionError(f"Missing rows from {source_file}.")
+ bad_sections = {
+ row.filing_section for row in source_rows if row.filing_section != expected_section
+ }
+ if bad_sections:
+ raise AssertionError(
+ f"{source_file} filing_section mismatch. Expected {expected_section}, found {bad_sections}."
+ )
+
+ missing_rows = [
+ key
+ for key, (expected_value, expected_unit) in EXPECTED_ROWS.items()
+ if key not in rows_by_key
+ or rows_by_key[key].value != expected_value
+ or rows_by_key[key].unit != expected_unit
+ ]
+ if missing_rows:
+ observed = sorted(rows_by_key)
+ raise AssertionError(
+ f"Missing or mismatched expected metric rows: {missing_rows}. Observed keys: {observed}."
+ )
+
+ unexpected_rows = sorted(set(rows_by_key) - set(EXPECTED_ROWS))
+ if unexpected_rows:
+ raise AssertionError(f"Unexpected metric rows found: {unexpected_rows}.")
+
+ return EvalSummary(row_count=len(rows))
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--artifact-path",
+ default=str(Path(__file__).resolve().parent / "output" / "financial_metrics.jsonl"),
+ help="Path to the generated JSONL or CSV artifact.",
+ )
+ args = parser.parse_args()
+
+ summary = validate_outputs(load_metrics(Path(args.artifact_path)))
+ print(f"Eval checks passed for {summary.row_count} metric row(s).")
diff --git a/examples/sandbox/tutorials/dataroom_metric_extract/main.py b/examples/sandbox/tutorials/dataroom_metric_extract/main.py
new file mode 100644
index 00000000..d31efc24
--- /dev/null
+++ b/examples/sandbox/tutorials/dataroom_metric_extract/main.py
@@ -0,0 +1,274 @@
+"""
+Extract structured financial metrics from a synthetic 10-K dataroom and write a
+JSONL or CSV artifact.
+"""
+
+import argparse
+import asyncio
+import csv
+import json
+import sys
+from collections.abc import Sequence
+from pathlib import Path
+from textwrap import dedent
+from typing import TYPE_CHECKING, Literal, cast
+
+from openai.types.shared.reasoning import Reasoning
+from pydantic import BaseModel
+
+from agents import ModelSettings, Runner, RunResultStreaming, TResponseInputItem
+from agents.run import RunConfig
+from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
+from agents.sandbox.capabilities import Shell
+from agents.sandbox.entries import File, LocalDir
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
+ sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
+
+if TYPE_CHECKING or __package__:
+ from .schemas import FinancialMetric, FinancialMetricBatch
+else:
+ from schemas import FinancialMetric, FinancialMetricBatch
+
+from examples.sandbox.tutorials.misc import (
+ DEFAULT_SANDBOX_IMAGE,
+ console,
+ create_sandbox_client_and_session,
+ load_env_defaults,
+ print_event,
+ run_interactive_loop,
+)
+
+DEMO_DIR = Path(__file__).resolve().parent
+DATAROOM_DATA_DIR = DEMO_DIR.parent / "data" / "dataroom"
+DEFAULT_QUESTION = (
+ "Extract revenue, gross margin, operating income, cash flow, balance-sheet, segment, "
+ "and geography metrics from the 10-K packet into one row per metric-period-source. "
+ "For each table, include every explicit line item in the source, even when it is "
+ "similar to a line item in another source."
+)
+AGENTS_MD = dedent(
+ """\
+ # AGENTS.md
+
+ Extract structured financial metrics from the synthetic 10-K packet under `data/`.
+
+ ## Output (one row per metric-value occurrence)
+
+ Required fields: `source_file`, `filing_section`, `metric_name`, `fiscal_period`, `value`,
+ `unit` (`USD millions` or `percent`).
+ Optional field: `segment` (segment/geography if explicitly stated, else null).
+
+ ## Rules
+
+ - Review all `.txt` and `.pdf` under `data/` (these PDFs contain searchable text).
+ - Use shell tools (`rg`, `sed`) for discovery/inspection; do not run Python from the sandbox shell.
+ - Do not read `data/setup.py`.
+ - Emit a separate row for each metric-period pair in each source file (do not dedupe across files).
+ - For tables, include every explicit table line item in that source. For example, the
+ statements-of-operations PDF has separate Net revenue, Gross profit, and Operating income rows.
+ - Only extract explicit source line items / table rows. Do not invent rollups or “cleaned up” metrics.
+ - Do not treat Gross profit and Gross margin as duplicates; they are distinct source metrics.
+ - Preserve labels as written (e.g., `Revenue` vs `Net revenue`).
+
+ ## Completeness checklist
+
+ Before final output, verify the batch has exactly 41 rows from these source-level line items:
+
+ - `data/10-k-mdna-overview.txt`: Revenue, Gross margin, and Operating income for FY2025 and FY2024.
+ - `data/10-k-mdna-liquidity.txt`: Net cash provided by operating activities, Capital expenditures,
+ and Free cash flow for FY2025 and FY2024.
+ - `data/10-k-note-segments.txt`: Platform segment revenue and Services segment revenue for FY2025
+ and FY2024, with the matching segment names.
+ - `data/10-k-note-geography.txt`: Americas revenue, EMEA revenue, and APAC revenue for FY2025, with
+ the matching geography names as segments.
+ - `data/10-k-note-balance-sheet.txt`: Cash and cash equivalents and Deferred revenue for 2025-12-31
+ and 2024-12-31.
+ - `data/10-k-statements-of-operations.pdf`: Net revenue, Gross profit, and Operating income for
+ FY2025 and FY2024.
+ - `data/10-k-balance-sheets.pdf`: Cash and cash equivalents, Accounts receivable, and Deferred revenue
+ for 2025-12-31 and 2024-12-31.
+ - `data/10-k-statements-of-cash-flows.pdf`: Net cash provided by operating activities, Capital
+ expenditures, and Free cash flow for FY2025 and FY2024.
+
+ Return the structured rows directly in your final output.
+ """
+)
+
+
+async def print_streamed_result(result: RunResultStreaming) -> BaseModel:
+ async for event in result.stream_events():
+ print_event(event)
+ if result.final_output is None:
+ raise RuntimeError("10-K Metric Extractor returned no structured metric output.")
+ print_event(str(result.final_output).strip())
+ return cast(BaseModel, result.final_output)
+
+
+def write_jsonl(path: Path, metrics: Sequence[BaseModel]) -> None:
+ path.write_text(
+ "\n".join(metric.model_dump_json() for metric in metrics) + "\n",
+ encoding="utf-8",
+ )
+
+
+def write_csv(path: Path, metrics: list[FinancialMetric]) -> None:
+ with path.open("w", encoding="utf-8", newline="") as output_file:
+ writer = csv.DictWriter(
+ output_file,
+ fieldnames=[
+ "source_file",
+ "filing_section",
+ "metric_name",
+ "fiscal_period",
+ "value",
+ "unit",
+ "segment",
+ ],
+ )
+ writer.writeheader()
+ for metric in metrics:
+ writer.writerow(json.loads(metric.model_dump_json()))
+
+
+def write_final_artifact(
+ output_dir: Path,
+ output_format: Literal["jsonl", "csv"],
+ metrics: list[FinancialMetric],
+) -> Path:
+ output_path = output_dir / f"financial_metrics.{output_format}"
+ if output_format == "jsonl":
+ write_jsonl(output_path, metrics)
+ else:
+ write_csv(output_path, metrics)
+ return output_path
+
+
+async def main(
+ model: str,
+ question: str,
+ output_format: Literal["jsonl", "csv"],
+ use_docker: bool,
+ image: str,
+ no_interactive: bool,
+) -> None:
+ if not (DATAROOM_DATA_DIR / "10-k-mdna-overview.txt").exists():
+ raise SystemExit(
+ "Run `uv run python examples/sandbox/tutorials/data/dataroom/setup.py` "
+ "before starting this demo."
+ )
+
+ manifest = Manifest(
+ entries={
+ "AGENTS.md": File(content=AGENTS_MD.encode("utf-8")),
+ "data": LocalDir(src=DATAROOM_DATA_DIR),
+ }
+ )
+ agent = SandboxAgent(
+ name="10-K Metric Extractor",
+ model=model,
+ instructions=AGENTS_MD,
+ capabilities=[Shell()],
+ model_settings=ModelSettings(
+ reasoning=Reasoning(effort="high"),
+ tool_choice="required",
+ ),
+ output_type=FinancialMetricBatch,
+ )
+
+ client, sandbox = await create_sandbox_client_and_session(
+ manifest=manifest,
+ use_docker=use_docker,
+ image=image,
+ )
+ try:
+ async with sandbox:
+ extracted_metrics: FinancialMetricBatch | None = None
+
+ async def run_turn(
+ conversation: list[TResponseInputItem],
+ ) -> list[TResponseInputItem]:
+ nonlocal extracted_metrics
+
+ result = Runner.run_streamed(
+ agent,
+ conversation,
+ max_turns=25,
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ tracing_disabled=True,
+ workflow_name="Dataroom extraction example",
+ ),
+ )
+ extracted_metrics = cast(FinancialMetricBatch, await print_streamed_result(result))
+ return result.to_input_list()
+
+ conversation: list[TResponseInputItem] = [{"role": "user", "content": question}]
+ conversation = await run_turn(conversation)
+ await run_interactive_loop(
+ conversation=conversation,
+ no_interactive=no_interactive,
+ run_turn=run_turn,
+ )
+ finally:
+ await client.delete(sandbox)
+
+ if extracted_metrics is None:
+ raise RuntimeError("10-K Metric Extractor returned no structured metric output.")
+
+ output_dir = DEMO_DIR / "output"
+ output_dir.mkdir(exist_ok=True)
+ artifact_path = write_final_artifact(output_dir, output_format, extracted_metrics.metrics)
+ console.print(
+ f"[green]Wrote {len(extracted_metrics.metrics)} metric row(s) to {artifact_path}[/green]"
+ )
+
+
+if __name__ == "__main__":
+ load_env_defaults(DEMO_DIR / ".env")
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--model",
+ default="gpt-5.4-mini",
+ help="Model name to use.",
+ )
+ parser.add_argument(
+ "--question",
+ default=DEFAULT_QUESTION,
+ help="Prompt to send to the agent.",
+ )
+ parser.add_argument(
+ "--output-format",
+ choices=("jsonl", "csv"),
+ default="csv",
+ help="Artifact format.",
+ )
+ parser.add_argument(
+ "--docker",
+ action="store_true",
+ help="Run this example in Docker instead of Unix-local.",
+ )
+ parser.add_argument(
+ "--image",
+ default=DEFAULT_SANDBOX_IMAGE,
+ help="Docker image to use when --docker is set.",
+ )
+ parser.add_argument(
+ "--no-interactive",
+ action="store_true",
+ help="Run the scripted turn and skip follow-up terminal input.",
+ )
+ args = parser.parse_args()
+
+ asyncio.run(
+ main(
+ args.model,
+ args.question,
+ args.output_format,
+ args.docker,
+ args.image,
+ args.no_interactive,
+ )
+ )
diff --git a/examples/sandbox/tutorials/dataroom_metric_extract/schemas.py b/examples/sandbox/tutorials/dataroom_metric_extract/schemas.py
new file mode 100644
index 00000000..6eeb2dcf
--- /dev/null
+++ b/examples/sandbox/tutorials/dataroom_metric_extract/schemas.py
@@ -0,0 +1,33 @@
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+class FinancialMetric(BaseModel):
+ source_file: str = Field(
+ description="Workspace-relative source path under data/, such as data/10-k-mdna-overview.txt."
+ )
+ filing_section: Literal[
+ "Part II, Item 7. Management's Discussion and Analysis of Financial Condition and Results of Operations",
+ "Part II, Item 8. Financial Statements and Supplementary Data",
+ ] = Field(description="Normalized 10-K filing section for the source document.")
+ metric_name: str = Field(
+ description="Metric label exactly as written in the source document or table."
+ )
+ fiscal_period: Literal["FY2025", "FY2024", "2025-12-31", "2024-12-31"] = Field(
+ description="Annual period label for statement rows, or balance-sheet date for point-in-time rows."
+ )
+ value: float = Field(description="Numeric value from the source row.")
+ unit: Literal["USD millions", "percent"] = Field(
+ description="Unit for `value`; use USD millions for dollar amounts and percent for margins."
+ )
+ segment: str | None = Field(
+ default=None,
+ description="Reportable segment or geography when the row is segment-specific, otherwise null.",
+ )
+
+
+class FinancialMetricBatch(BaseModel):
+ metrics: list[FinancialMetric] = Field(
+ description="One row per metric-period pair extracted from each source document."
+ )
diff --git a/examples/sandbox/tutorials/dataroom_qa/README.md b/examples/sandbox/tutorials/dataroom_qa/README.md
new file mode 100644
index 00000000..2ffb72ed
--- /dev/null
+++ b/examples/sandbox/tutorials/dataroom_qa/README.md
@@ -0,0 +1,52 @@
+# Dataroom Q&A
+
+## Goal
+
+Answer grounded financial questions over a synthetic 10-K packet.
+
+The packet uses synthetic company data, but the documents are shaped like annual
+report excerpts: MD&A text uses 10-K `Part II, Item 7`, while statement PDFs and
+footnote text use `Part II, Item 8`.
+
+## Why this is valuable
+
+This demo shows a retrieval-first agent pattern over a bounded financial corpus
+where each metric and explanation should stay tied to source files.
+
+## Setup
+
+Run the fixture generator and then the Unix-local example from the repository
+root. Set `OPENAI_API_KEY` in your shell environment before running the example.
+
+```bash
+uv run python examples/sandbox/tutorials/data/dataroom/setup.py
+uv run python examples/sandbox/tutorials/dataroom_qa/main.py
+```
+
+After the initial answer, the demo keeps the sandbox session open for
+Rich-rendered follow-up prompts. Pass `--no-interactive` for a one-shot run.
+
+To run the same manifest in Docker, build the shared tutorial image once and pass
+`--docker`:
+
+```bash
+docker build --tag sandbox-tutorials:latest examples/sandbox/tutorials
+uv run python examples/sandbox/tutorials/dataroom_qa/main.py --docker
+```
+
+## Expected artifacts
+
+- A direct cited answer in the streamed agent response.
+- Citations use `[n](data/source-file.txt:line:14)` for text excerpts and
+ `[n](data/source-file.pdf:page:1)` for the one-page synthetic PDFs.
+
+## Demo shape
+
+- Inputs: 5 synthetic filing text docs and 3 simple filing PDFs from `examples/sandbox/tutorials/data/dataroom/`.
+- Runtime primitives: sandbox-local bash/file search.
+
+## How instructions are loaded
+
+At startup, the wrapper loads this folder's `AGENTS.md` into the agent
+instructions and builds a hard-coded manifest that maps the shared SEC packet
+from `examples/sandbox/tutorials/data/dataroom/` into the sandbox as `data/...`.
diff --git a/examples/sandbox/tutorials/dataroom_qa/__init__.py b/examples/sandbox/tutorials/dataroom_qa/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/examples/sandbox/tutorials/dataroom_qa/__init__.py
@@ -0,0 +1 @@
+
diff --git a/examples/sandbox/tutorials/dataroom_qa/main.py b/examples/sandbox/tutorials/dataroom_qa/main.py
new file mode 100644
index 00000000..4ce33a29
--- /dev/null
+++ b/examples/sandbox/tutorials/dataroom_qa/main.py
@@ -0,0 +1,146 @@
+"""
+Answer questions over a synthetic dataroom.
+"""
+
+import argparse
+import asyncio
+import sys
+from pathlib import Path
+from textwrap import dedent
+
+from agents import Runner, RunResultStreaming, TResponseInputItem
+from agents.run import RunConfig
+from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
+from agents.sandbox.capabilities import Shell
+from agents.sandbox.entries import File, LocalDir
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
+
+from examples.sandbox.tutorials.misc import (
+ DEFAULT_SANDBOX_IMAGE,
+ create_sandbox_client_and_session,
+ load_env_defaults,
+ print_event,
+ run_interactive_loop,
+)
+
+DEMO_DIR = Path(__file__).resolve().parent
+DATAROOM_DATA_DIR = DEMO_DIR.parent / "data" / "dataroom"
+DEFAULT_QUESTION = (
+ "How did revenue, gross margin, operating income, and operating cash flow change in "
+ "FY2025 versus FY2024, and which segment contributed the most revenue?"
+)
+AGENTS_MD = dedent(
+ """\
+ # AGENTS.md
+
+ Answer the user's financial question using only the synthetic 10-K packet in `data/`.
+
+ ## Evidence & citations
+
+ - Cite every material claim with markdown links in these formats (no bare links):
+ - `[1](data/source-file.txt:line:14)` for text sources
+ - `[2](data/source-file.pdf:page:1)` for PDF sources (each synthetic PDF is one page)
+ - Use `rg` and `sed` to find and quote exact evidence; do not use `data/setup.py`.
+
+ Keep the final answer direct and finance-oriented.
+ """
+)
+
+
+async def print_streamed_result(result: RunResultStreaming) -> list[TResponseInputItem]:
+ async for event in result.stream_events():
+ print_event(event)
+ print_event(str(result.final_output).strip())
+ return result.to_input_list()
+
+
+async def main(
+ model: str, question: str, use_docker: bool, image: str, no_interactive: bool
+) -> None:
+ if not (DATAROOM_DATA_DIR / "10-k-mdna-overview.txt").exists():
+ raise SystemExit(
+ "Run `uv run python examples/sandbox/tutorials/data/dataroom/setup.py` "
+ "before starting this demo."
+ )
+
+ manifest = Manifest(
+ entries={
+ "AGENTS.md": File(content=AGENTS_MD.encode("utf-8")),
+ "data": LocalDir(src=DATAROOM_DATA_DIR),
+ }
+ )
+ agent = SandboxAgent(
+ name="Dataroom Analyst",
+ model=model,
+ instructions=AGENTS_MD,
+ capabilities=[Shell()],
+ )
+
+ client, sandbox = await create_sandbox_client_and_session(
+ manifest=manifest,
+ use_docker=use_docker,
+ image=image,
+ )
+ try:
+ async with sandbox:
+
+ async def run_turn(
+ conversation: list[TResponseInputItem],
+ ) -> list[TResponseInputItem]:
+ result = Runner.run_streamed(
+ agent,
+ conversation,
+ max_turns=20,
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ tracing_disabled=True,
+ workflow_name="Dataroom Q&A example",
+ ),
+ )
+ return await print_streamed_result(result)
+
+ conversation: list[TResponseInputItem] = [{"role": "user", "content": question}]
+ conversation = await run_turn(conversation)
+ await run_interactive_loop(
+ conversation=conversation,
+ no_interactive=no_interactive,
+ run_turn=run_turn,
+ )
+ finally:
+ await client.delete(sandbox)
+
+
+if __name__ == "__main__":
+ load_env_defaults(DEMO_DIR / ".env")
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--model",
+ default="gpt-5.4-mini",
+ help="Model name to use.",
+ )
+ parser.add_argument(
+ "--question",
+ default=DEFAULT_QUESTION,
+ help="Prompt to send to the agent.",
+ )
+ parser.add_argument(
+ "--docker",
+ action="store_true",
+ help="Run this example in Docker instead of Unix-local.",
+ )
+ parser.add_argument(
+ "--image",
+ default=DEFAULT_SANDBOX_IMAGE,
+ help="Docker image to use when --docker is set.",
+ )
+ parser.add_argument(
+ "--no-interactive",
+ action="store_true",
+ help="Run the scripted turn and skip follow-up terminal input.",
+ )
+ args = parser.parse_args()
+
+ asyncio.run(main(args.model, args.question, args.docker, args.image, args.no_interactive))
diff --git a/examples/sandbox/tutorials/misc.py b/examples/sandbox/tutorials/misc.py
new file mode 100644
index 00000000..80552482
--- /dev/null
+++ b/examples/sandbox/tutorials/misc.py
@@ -0,0 +1,397 @@
+import json
+import os
+import subprocess
+from collections.abc import Awaitable, Callable
+from pathlib import Path
+from typing import Any, Literal, TypeAlias, cast
+
+from openai.types.responses import (
+ ResponseComputerToolCall,
+ ResponseFileSearchToolCall,
+ ResponseFunctionToolCall,
+ ResponseFunctionWebSearch,
+)
+from openai.types.responses.response_code_interpreter_tool_call import (
+ ResponseCodeInterpreterToolCall,
+)
+from openai.types.responses.response_output_item import ImageGenerationCall, LocalShellCall, McpCall
+from pydantic import BaseModel, Field
+from rich import box
+from rich.console import Console, Group
+from rich.markdown import Markdown
+from rich.panel import Panel
+from rich.pretty import Pretty
+from rich.prompt import Prompt
+from rich.syntax import Syntax
+from rich.text import Text
+from typing_extensions import TypedDict
+
+from agents import ItemHelpers, TResponseInputItem
+from agents.items import (
+ CompactionItem,
+ HandoffCallItem,
+ HandoffOutputItem,
+ MCPApprovalRequestItem,
+ MCPApprovalResponseItem,
+ MCPListToolsItem,
+ MessageOutputItem,
+ ReasoningItem,
+ ToolApprovalItem,
+ ToolCallItem,
+ ToolCallOutputItem,
+ ToolSearchCallItem,
+ ToolSearchOutputItem,
+)
+from agents.sandbox import Manifest
+from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
+from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
+from agents.sandbox.session import BaseSandboxClient, SandboxSession
+from agents.stream_events import (
+ AgentUpdatedStreamEvent,
+ RawResponsesStreamEvent,
+ StreamEvent,
+)
+from examples.auto_mode import input_with_fallback, is_auto_mode
+
+DEFAULT_SANDBOX_IMAGE = "sandbox-tutorials:latest"
+console = Console()
+PanelBody = Group | Pretty | Text
+PrintableEvent: TypeAlias = StreamEvent | str
+SandboxClient: TypeAlias = BaseSandboxClient[Any]
+InteractiveTurnRunner: TypeAlias = Callable[
+ [list[TResponseInputItem]], Awaitable[list[TResponseInputItem]]
+]
+
+
+class ApplyPatchOperationPayload(TypedDict):
+ path: str
+ type: Literal["create_file", "update_file", "delete_file"]
+ diff: str
+
+
+class ApplyPatchCallPayload(TypedDict):
+ type: Literal["apply_patch_call"]
+ call_id: str
+ operation: ApplyPatchOperationPayload
+
+
+class Question(BaseModel):
+ query: str = Field(description="User-facing question to ask.")
+ options: list[str] = Field(
+ default_factory=list,
+ description="Suggested answer options. The UI always adds a custom free-text choice.",
+ )
+
+
+class QuestionAnswer(BaseModel):
+ question: str = Field(description="The question that was asked.")
+ answer: str = Field(description="The user's selected or free-text answer.")
+
+
+def load_env_defaults(env_path: Path) -> None:
+ if not env_path.exists():
+ return
+
+ for raw_line in env_path.read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+
+ key, value = line.split("=", 1)
+ normalized_key = key.strip()
+ normalized_value = value.strip().strip('"').strip("'")
+ if normalized_key:
+ os.environ.setdefault(normalized_key, normalized_value)
+
+
+async def create_sandbox_client_and_session(
+ *,
+ manifest: Manifest,
+ use_docker: bool,
+ image: str = DEFAULT_SANDBOX_IMAGE,
+) -> tuple[SandboxClient, SandboxSession]:
+ if use_docker:
+ try:
+ from docker import from_env as docker_from_env # type: ignore[import-untyped]
+ except ImportError as exc:
+ raise SystemExit(
+ "Docker-backed runs require the Docker SDK. Install repo dependencies with `make sync`."
+ ) from exc
+
+ client: SandboxClient = DockerSandboxClient(
+ docker_from_env(environment=build_docker_environment())
+ )
+ sandbox = await client.create(
+ manifest=manifest,
+ options=DockerSandboxClientOptions(image=image),
+ )
+ return client, sandbox
+
+ client = UnixLocalSandboxClient()
+ sandbox = await client.create(manifest=manifest)
+ return client, sandbox
+
+
+def build_docker_environment() -> dict[str, str]:
+ environment = os.environ.copy()
+ if environment.get("DOCKER_HOST") or environment.get("DOCKER_CONTEXT"):
+ return environment
+
+ # Respect whichever Docker context the CLI is currently using, including Docker Desktop
+ # and Colima, without taking a direct dependency on a specific daemon provider.
+ try:
+ result = subprocess.run(
+ ["docker", "context", "inspect", "--format", "{{json .Endpoints.docker.Host}}"],
+ capture_output=True,
+ check=True,
+ text=True,
+ )
+ docker_host = json.loads(result.stdout.strip() or "null")
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
+ return environment
+
+ if isinstance(docker_host, str) and docker_host:
+ environment["DOCKER_HOST"] = docker_host
+ return environment
+
+
+def prompt_with_fallback(prompt: str, fallback: str) -> str:
+ if is_auto_mode():
+ return input_with_fallback(prompt, fallback).strip()
+
+ try:
+ return Prompt.ask(prompt).strip()
+ except (EOFError, KeyboardInterrupt):
+ return fallback
+
+
+def ask_user_questions(questions: list[Question]) -> list[QuestionAnswer]:
+ answers: list[QuestionAnswer] = []
+
+ for question_index, question in enumerate(questions, start=1):
+ suggested_options = [option.strip() for option in question.options if option.strip()]
+ custom_choice_index = len(suggested_options) + 1
+ options_text = Text.from_markup(
+ "\n".join(
+ [
+ *(
+ f"[cyan]{index}.[/cyan] {option}"
+ for index, option in enumerate(
+ suggested_options,
+ start=1,
+ )
+ ),
+ f"[cyan]{custom_choice_index}.[/cyan] Use your own text",
+ ]
+ )
+ )
+
+ console.print(
+ Panel(
+ Group(
+ Text(question.query),
+ options_text,
+ ),
+ title=f"Question {question_index}",
+ border_style="magenta",
+ box=box.ROUNDED,
+ expand=False,
+ )
+ )
+
+ while True:
+ choice = prompt_with_fallback(
+ f"[bold cyan]Select[/bold cyan] 1-{custom_choice_index}",
+ "1" if suggested_options else str(custom_choice_index),
+ )
+ if choice.isdigit() and 1 <= int(choice) <= len(suggested_options):
+ answer = suggested_options[int(choice) - 1]
+ break
+ if choice.isdigit() and int(choice) == custom_choice_index:
+ answer = prompt_with_fallback(
+ "[bold cyan]Your answer[/bold cyan]",
+ suggested_options[0] if suggested_options else "Use a conservative assumption.",
+ )
+ if answer:
+ break
+ continue
+ if choice and not choice.isdigit():
+ answer = choice
+ break
+
+ console.print(
+ f"[red]Please enter a number from 1 to {custom_choice_index}, or custom text.[/red]"
+ )
+
+ answers.append(QuestionAnswer(question=question.query, answer=answer))
+
+ console.print(
+ Panel(
+ Pretty([answer.model_dump(mode="json") for answer in answers], expand_all=True),
+ title="Question answers",
+ border_style="magenta",
+ box=box.ROUNDED,
+ expand=False,
+ )
+ )
+ return answers
+
+
+async def run_interactive_loop(
+ *,
+ conversation: list[TResponseInputItem],
+ no_interactive: bool,
+ run_turn: InteractiveTurnRunner,
+) -> list[TResponseInputItem]:
+ if no_interactive or is_auto_mode():
+ return conversation
+
+ console.print("[dim]Enter follow-up prompts. Press Ctrl-D or Ctrl-C to finish.[/dim]")
+ while True:
+ try:
+ next_message = Prompt.ask("[bold cyan]user[/bold cyan]").strip()
+ except (EOFError, KeyboardInterrupt):
+ break
+
+ if not next_message:
+ continue
+
+ conversation.append({"role": "user", "content": next_message})
+ conversation = await run_turn(conversation)
+
+ return conversation
+
+
+def print_event(event: PrintableEvent) -> None:
+ if isinstance(event, str):
+ console.print()
+ console.rule("[bold green]Final output[/bold green]", style="green")
+ console.print(
+ Panel(
+ Markdown(event or "_No final output returned._"),
+ border_style="green",
+ box=box.ROUNDED,
+ expand=False,
+ )
+ )
+ return
+
+ if isinstance(event, AgentUpdatedStreamEvent):
+ console.print(
+ Panel(
+ Pretty(event.new_agent.name, expand_all=True),
+ title="Agent updated",
+ border_style="cyan",
+ box=box.ROUNDED,
+ expand=False,
+ )
+ )
+ return
+
+ if isinstance(event, RawResponsesStreamEvent):
+ return
+
+ body: PanelBody
+ match event.item:
+ case ReasoningItem() as item:
+ body = Pretty(item, expand_all=True)
+ title = f"Reasoning item: {event.name.replace('_', ' ')}"
+ case ToolCallItem() as item:
+ tool_name = "tool"
+ body = Pretty(item.raw_item, expand_all=True)
+ match item.raw_item:
+ case ResponseFunctionToolCall() as raw_item:
+ tool_name = raw_item.name
+ payload = json.loads(raw_item.arguments) if raw_item.arguments else {}
+ if tool_name == "exec_command":
+ command = payload["cmd"]
+ if "\\n" in command and "\n" not in command:
+ command = command.replace("\\n", "\n")
+ body = Group(
+ Pretty(
+ {key: value for key, value in payload.items() if key != "cmd"},
+ expand_all=True,
+ ),
+ Syntax(command, "bash", theme="ansi_dark", word_wrap=True),
+ )
+ else:
+ body = Pretty(payload, expand_all=True)
+ case ResponseComputerToolCall() as raw_item:
+ tool_name = "computer"
+ body = Pretty(raw_item, expand_all=True)
+ case ResponseFileSearchToolCall() as raw_item:
+ tool_name = "file_search"
+ body = Pretty(raw_item, expand_all=True)
+ case ResponseFunctionWebSearch() as raw_item:
+ tool_name = "web_search"
+ body = Pretty(raw_item, expand_all=True)
+ case McpCall() as raw_item:
+ tool_name = "mcp"
+ body = Pretty(raw_item, expand_all=True)
+ case ResponseCodeInterpreterToolCall() as raw_item:
+ tool_name = "code_interpreter"
+ body = Pretty(raw_item, expand_all=True)
+ case ImageGenerationCall() as raw_item:
+ tool_name = "image_generation"
+ body = Pretty(raw_item, expand_all=True)
+ case LocalShellCall() as raw_item:
+ tool_name = "local_shell"
+ body = Pretty(raw_item, expand_all=True)
+ case dict() as raw_item:
+ tool_name = "apply_patch"
+ payload = cast(ApplyPatchCallPayload, raw_item)["operation"]
+ body = Group(
+ Pretty(
+ {
+ "path": payload["path"],
+ "type": payload["type"],
+ },
+ expand_all=True,
+ ),
+ Syntax(payload["diff"], "diff", theme="ansi_dark", word_wrap=True),
+ )
+ title = f"Tool call: {tool_name}"
+ case ToolCallOutputItem() as item:
+ body = Text(item.output) if isinstance(item.output, str) else Pretty(item.output)
+ title = "Tool output"
+ case MessageOutputItem() as item:
+ output = ItemHelpers.text_message_output(item)
+ body = Text(output) if isinstance(output, str) else Pretty(output, expand_all=True)
+ title = "Message output"
+ case ToolSearchCallItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "Tool search call"
+ case ToolSearchOutputItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "Tool search output"
+ case HandoffCallItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "Handoff call"
+ case HandoffOutputItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "Handoff output"
+ case MCPListToolsItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "MCP list tools"
+ case MCPApprovalRequestItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "MCP approval request"
+ case MCPApprovalResponseItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "MCP approval response"
+ case CompactionItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "Compaction"
+ case ToolApprovalItem() as item:
+ body = Pretty(item.raw_item, expand_all=True)
+ title = "Tool approval"
+
+ console.print(
+ Panel(
+ body,
+ title=title,
+ border_style="cyan",
+ box=box.ROUNDED,
+ expand=False,
+ )
+ )
diff --git a/examples/sandbox/tutorials/repo_code_review/README.md b/examples/sandbox/tutorials/repo_code_review/README.md
new file mode 100644
index 00000000..75eddaeb
--- /dev/null
+++ b/examples/sandbox/tutorials/repo_code_review/README.md
@@ -0,0 +1,56 @@
+# Repo code review
+
+## Goal
+
+Review a small public git repository, run its tests, leave line-level review
+comments in the structured output, and write a patch-oriented review artifact.
+
+## Why this is valuable
+
+This demo shows a coding-agent workflow where the sandbox can inspect a real
+git worktree, run tests, reason over a diff, and produce review artifacts that a
+developer can act on. The manifest mounts `pypa/sampleproject` at a pinned ref
+with `GitRepo(...)`.
+The review contract is intentionally narrow: one finding should target the CI
+workflow, and one should target the missing type hints in `src/sample/simple.py`.
+
+## Setup
+
+Run the Unix-local example from the repository root:
+
+```bash
+uv run python examples/sandbox/tutorials/repo_code_review/main.py
+uv run python examples/sandbox/tutorials/repo_code_review/evals.py
+```
+
+This demo exits after the scripted review so the generated artifacts and eval
+contract stay deterministic.
+
+To run the same review in Docker, build the shared tutorial image once and pass
+`--docker`:
+
+```bash
+docker build -t sandbox-tutorials:latest -f examples/sandbox/tutorials/Dockerfile .
+uv run python examples/sandbox/tutorials/repo_code_review/main.py --docker
+uv run python examples/sandbox/tutorials/repo_code_review/evals.py
+```
+
+## Expected artifacts
+
+- `output/review.md`
+- `output/findings.jsonl`
+- Optional `output/fix.patch`
+
+## Demo shape
+
+- Inputs: `pypa/sampleproject` at a pinned git ref, mounted into the workspace
+ as `repo/`.
+- Runtime primitives: sandbox-local bash, optional file edits, and a typed
+ `RepoReviewResult` final output.
+- Workflow: one sandbox reviewer agent is enough here; there is no handoff
+ because the task is a linear inspect -> test -> patch -> summarize loop.
+- Scratch space: the reviewer can use `scratchpad/` for notes or draft diffs,
+ then return the final review object for the wrapper to persist.
+- Evals: `evals.py` checks that the two findings stay focused on `uv` in the
+ test workflow and type hints in `src/sample/simple.py`, and that the patch
+ only edits `simple.py`.
diff --git a/examples/sandbox/tutorials/repo_code_review/__init__.py b/examples/sandbox/tutorials/repo_code_review/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/examples/sandbox/tutorials/repo_code_review/__init__.py
@@ -0,0 +1 @@
+
diff --git a/examples/sandbox/tutorials/repo_code_review/evals.py b/examples/sandbox/tutorials/repo_code_review/evals.py
new file mode 100644
index 00000000..532b36cb
--- /dev/null
+++ b/examples/sandbox/tutorials/repo_code_review/evals.py
@@ -0,0 +1,79 @@
+"""Evaluate the repo code-review demo outputs."""
+
+import argparse
+import json
+from pathlib import Path
+
+EXPECTED_FINDING_PATHS = {
+ "repo/.github/workflows/test.yml",
+ "repo/src/sample/simple.py",
+}
+
+
+def load_findings(findings_path: Path) -> list[dict[str, object]]:
+ return [
+ json.loads(line)
+ for line in findings_path.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+
+
+def validate_findings(findings: list[dict[str, object]]) -> None:
+ if len(findings) != 2:
+ raise ValueError(f"Expected 2 review findings, got {len(findings)}.")
+
+ finding_paths = {str(finding["file"]) for finding in findings}
+ if finding_paths != EXPECTED_FINDING_PATHS:
+ raise ValueError(
+ f"Expected findings for {sorted(EXPECTED_FINDING_PATHS)}, got {sorted(finding_paths)}."
+ )
+
+ workflow_comment = next(
+ str(finding["comment"])
+ for finding in findings
+ if finding["file"] == "repo/.github/workflows/test.yml"
+ )
+ workflow_words = {word.strip("`.,:;()[]{}").lower() for word in workflow_comment.split()}
+ if "nox" not in workflow_words:
+ raise ValueError("Expected the workflow review comment to mention nox.")
+ if not ({"uv", "pip", "install", "project", "test"} & workflow_words):
+ raise ValueError(
+ "Expected the workflow review comment to describe a concrete test-tooling concern."
+ )
+
+ simple_comment = next(
+ str(finding["comment"])
+ for finding in findings
+ if finding["file"] == "repo/src/sample/simple.py"
+ )
+ if "add_one" not in simple_comment or "-> int" not in simple_comment:
+ raise ValueError("Expected the simple.py review comment to suggest type hints for add_one.")
+
+
+def validate_patch(patch_path: Path) -> None:
+ patch_text = patch_path.read_text(encoding="utf-8")
+ if "src/sample/simple.py" not in patch_text:
+ raise ValueError("Expected the patch to modify src/sample/simple.py.")
+ if ".github/workflows/test.yml" in patch_text or "noxfile.py" in patch_text:
+ raise ValueError("Expected the patch to avoid CI and noxfile changes.")
+ if "def add_one(number: int) -> int:" not in patch_text:
+ raise ValueError("Expected the patch to add type hints to add_one.")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--output-dir",
+ type=Path,
+ default=Path(__file__).resolve().parent / "output",
+ help="Directory containing findings.jsonl and fix.patch.",
+ )
+ args = parser.parse_args()
+
+ validate_findings(load_findings(args.output_dir / "findings.jsonl"))
+ validate_patch(args.output_dir / "fix.patch")
+ print("Repo review eval checks passed.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/sandbox/tutorials/repo_code_review/main.py b/examples/sandbox/tutorials/repo_code_review/main.py
new file mode 100644
index 00000000..7f951059
--- /dev/null
+++ b/examples/sandbox/tutorials/repo_code_review/main.py
@@ -0,0 +1,173 @@
+"""
+Review a small GitHub repository and produce sandbox-generated findings artifacts.
+"""
+
+import argparse
+import asyncio
+import json
+import sys
+from pathlib import Path
+from textwrap import dedent
+from typing import cast
+
+from pydantic import BaseModel, Field
+
+from agents import ModelSettings, Runner
+from agents.run import RunConfig
+from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
+from agents.sandbox.capabilities import Filesystem, Shell
+from agents.sandbox.entries import File, GitRepo
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
+
+from examples.sandbox.tutorials.misc import (
+ DEFAULT_SANDBOX_IMAGE,
+ console,
+ create_sandbox_client_and_session,
+ load_env_defaults,
+ print_event,
+)
+
+DEMO_DIR = Path(__file__).resolve().parent
+REPO_NAME = "pypa/sampleproject"
+REPO_REF = "621e4974ca25ce531773def586ba3ed8e736b3fc"
+DEFAULT_QUESTION = (
+ "Review this small Python repository as a maintainer. Run the tests, inspect the "
+ "project layout, and return exactly two concise line-level findings: one for "
+ "`repo/.github/workflows/test.yml` about concrete nox/test installation reliability, "
+ "and one for `repo/src/sample/simple.py` about adding explicit type hints to "
+ "`add_one`. Return a patch artifact for the obvious `simple.py` type-hint fix."
+)
+AGENTS_MD = dedent(
+ """\
+ # AGENTS.md
+
+ Review the mounted repository under `repo/` like a maintainer.
+
+ - Run `uv run python -m unittest discover -s tests` from `repo/` and report a short result summary.
+ - Return exactly two findings, using these exact file paths:
+ - `repo/.github/workflows/test.yml`: mention nox and a concrete test-tooling/install concern.
+ - `repo/src/sample/simple.py`: mention `add_one` and suggest `-> int` type hints.
+ - Do not return findings for `pyproject.toml`, `noxfile.py`, README files, or tests.
+ - Do not edit the mounted repository. Return the suggested patch text in `fix_patch`.
+ - Set `fix_patch` to a minimal git diff that only edits `repo/src/sample/simple.py` by changing
+ `def add_one(number):` to `def add_one(number: int) -> int:`.
+ - If you inspect files with shell commands, use paths under `repo/`; use `rg`.
+ """
+)
+
+
+class ReviewFinding(BaseModel):
+ file: str = Field(
+ description=(
+ "Exact workspace-relative path under repo/. Preserve casing from the workspace file listing."
+ )
+ )
+ line_number: int = Field(description="1-based line number for the review comment.")
+ comment: str = Field(
+ description=(
+ "Concrete review comment for that line. Include a tiny git-diff-style "
+ "suggestion in the comment when the fix is obvious."
+ )
+ )
+
+
+class RepoReviewResult(BaseModel):
+ test_command: str = Field(description="Exact test command that was run.")
+ test_result: str = Field(description="Short summary of the test outcome.")
+ findings: list[ReviewFinding] = Field(description="Review findings ordered by severity.")
+ review_markdown: str = Field(description="Human-readable review summary in Markdown.")
+ fix_patch: str | None = Field(
+ description="A minimal git diff patch if a fix was made, otherwise null."
+ )
+
+
+def write_review_artifacts(output_dir: Path, review: RepoReviewResult) -> None:
+ output_dir.mkdir(exist_ok=True)
+ (output_dir / "review.md").write_text(review.review_markdown.strip() + "\n", encoding="utf-8")
+ (output_dir / "findings.jsonl").write_text(
+ "\n".join(
+ json.dumps(finding.model_dump(mode="json"), sort_keys=True)
+ for finding in review.findings
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ if review.fix_patch:
+ (output_dir / "fix.patch").write_text(review.fix_patch.strip() + "\n", encoding="utf-8")
+
+
+async def main(model: str, question: str, use_docker: bool, image: str) -> None:
+ manifest = Manifest(
+ entries={
+ "AGENTS.md": File(content=AGENTS_MD.encode("utf-8")),
+ "repo": GitRepo(repo=REPO_NAME, ref=REPO_REF),
+ }
+ )
+ agent = SandboxAgent(
+ name="Code Reviewer",
+ model=model,
+ instructions=AGENTS_MD,
+ capabilities=[Shell(), Filesystem()],
+ model_settings=ModelSettings(tool_choice="required"),
+ output_type=RepoReviewResult,
+ )
+
+ client, sandbox = await create_sandbox_client_and_session(
+ manifest=manifest,
+ use_docker=use_docker,
+ image=image,
+ )
+ try:
+ async with sandbox:
+ result = Runner.run_streamed(
+ agent,
+ [{"role": "user", "content": question}],
+ max_turns=25,
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ tracing_disabled=True,
+ workflow_name="Repo Review example",
+ ),
+ )
+ async for event in result.stream_events():
+ print_event(event)
+ if result.final_output is None:
+ raise RuntimeError("Code Reviewer returned no structured review output.")
+ print_event(str(result.final_output).strip())
+ review = cast(RepoReviewResult, result.final_output)
+ finally:
+ await client.delete(sandbox)
+
+ write_review_artifacts(DEMO_DIR / "output", review)
+ console.print(f"[green]Wrote review artifacts to {DEMO_DIR / 'output'}[/green]")
+
+
+if __name__ == "__main__":
+ load_env_defaults(DEMO_DIR / ".env")
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--model",
+ default="gpt-5.4-mini",
+ help="Model name to use.",
+ )
+ parser.add_argument(
+ "--question",
+ default=DEFAULT_QUESTION,
+ help="Prompt to send to the agent.",
+ )
+ parser.add_argument(
+ "--docker",
+ action="store_true",
+ help="Run this example in Docker instead of Unix-local.",
+ )
+ parser.add_argument(
+ "--image",
+ default=DEFAULT_SANDBOX_IMAGE,
+ help="Docker image to use when --docker is set.",
+ )
+ args = parser.parse_args()
+
+ asyncio.run(main(args.model, args.question, args.docker, args.image))
diff --git a/examples/sandbox/tutorials/sandbox_resume/README.md b/examples/sandbox/tutorials/sandbox_resume/README.md
new file mode 100644
index 00000000..323849ed
--- /dev/null
+++ b/examples/sandbox/tutorials/sandbox_resume/README.md
@@ -0,0 +1,37 @@
+# Sandbox resume
+
+This example shows a small sandbox resume flow with `AGENTS.md`
+mounted in the sandbox and loaded into the agent instructions. It runs in two
+steps: first it builds the app and smoke tests it, then it serializes the
+sandbox session state, resumes the sandbox, and adds pytest coverage.
+
+By default the agent builds a tiny warehouse-robot status API, smoke-tests it,
+then resumes the same sandbox to add tests. The sandbox workspace starts with
+one instruction file:
+
+- `AGENTS.md` with instructions to build FastAPI apps, use type hints and
+ Pydantic, install dependencies with `uv`, run Python commands through
+ `uv run python`, and test locally before finishing.
+
+Run the example from the repository root:
+
+```bash
+uv run python examples/sandbox/tutorials/sandbox_resume/main.py
+```
+
+This demo exits after the scripted resume flow so the serialized session state
+and resume step stay easy to follow.
+
+You can override the model or prompt:
+
+```bash
+uv run python examples/sandbox/tutorials/sandbox_resume/main.py --model gpt-5.4 --question "Build a FastAPI service that exposes a warehouse robot's maintenance status."
+```
+
+To run the same flow in Docker, build the shared tutorial image once and pass
+`--docker`:
+
+```bash
+docker build --tag sandbox-tutorials:latest examples/sandbox/tutorials
+uv run python examples/sandbox/tutorials/sandbox_resume/main.py --docker
+```
diff --git a/examples/sandbox/tutorials/sandbox_resume/__init__.py b/examples/sandbox/tutorials/sandbox_resume/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/examples/sandbox/tutorials/sandbox_resume/__init__.py
@@ -0,0 +1 @@
+
diff --git a/examples/sandbox/tutorials/sandbox_resume/main.py b/examples/sandbox/tutorials/sandbox_resume/main.py
new file mode 100644
index 00000000..2a9811f3
--- /dev/null
+++ b/examples/sandbox/tutorials/sandbox_resume/main.py
@@ -0,0 +1,145 @@
+"""
+Show the smallest Unix-local sandbox flow with workspace instructions.
+
+The manifest includes an AGENTS.md file that tells the agent how to build the
+app, and the prompt asks for a tiny FastAPI operations status API with a health
+check.
+"""
+
+import argparse
+import asyncio
+import sys
+from pathlib import Path
+from textwrap import dedent
+
+from agents import Runner, RunResultStreaming, TResponseInputItem
+from agents.run import RunConfig
+from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
+from agents.sandbox.capabilities import Filesystem, Shell
+from agents.sandbox.entries import File
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
+
+from examples.sandbox.tutorials.misc import (
+ DEFAULT_SANDBOX_IMAGE,
+ create_sandbox_client_and_session,
+ load_env_defaults,
+ print_event,
+)
+
+DEFAULT_QUESTION = (
+ "Build a small warehouse-robot operations status API with FastAPI. Include a health "
+ "check, a typed `/robots/{robot_id}/status` endpoint backed by a tiny in-memory "
+ "fixture, and clear 404 behavior. Install dependencies with uv, smoke test it locally "
+ "with `uv run python` and `urllib.request`, and summarize what you built."
+)
+DEMO_DIR = Path(__file__).resolve().parent
+RESUME_QUESTION = (
+ "Now add pytest coverage for the health check, robot status success case, and unknown "
+ "robot 404 case. Install any missing dependencies with uv, run the tests locally, and "
+ "summarize the files you changed."
+)
+AGENTS_MD = dedent(
+ """\
+ # AGENTS.md
+
+ - When asked to build an app, make it a FastAPI app.
+ - Use type hints and Pydantic models.
+ - Use `uv` when installing dependencies.
+ - Run Python commands as `uv run python ...`, not bare `python`.
+ - Smoke test local HTTP endpoints with `uv run python` and `urllib.request`, not `curl`.
+ - Test the app locally before finishing.
+ """
+)
+
+
+async def run_step(result: RunResultStreaming) -> list[TResponseInputItem]:
+ async for event in result.stream_events():
+ print_event(event)
+ print_event(str(result.final_output).strip())
+ return result.to_input_list()
+
+
+async def main(model: str, question: str, use_docker: bool, image: str) -> None:
+ manifest = Manifest(entries={"AGENTS.md": File(content=AGENTS_MD.encode("utf-8"))})
+ agent = SandboxAgent(
+ name="Vibe Coder",
+ model=model,
+ instructions=AGENTS_MD,
+ capabilities=[Shell(), Filesystem()],
+ )
+
+ client, sandbox = await create_sandbox_client_and_session(
+ manifest=manifest,
+ use_docker=use_docker,
+ image=image,
+ )
+ conversation: list[TResponseInputItem] = [{"role": "user", "content": question}]
+
+ try:
+ async with sandbox:
+ result = Runner.run_streamed(
+ agent,
+ conversation,
+ max_turns=20,
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ tracing_disabled=True,
+ workflow_name="Sandbox resume example",
+ ),
+ )
+ conversation = await run_step(result)
+
+ frozen_session_state = client.deserialize_session_state(
+ client.serialize_session_state(sandbox.state)
+ )
+ conversation.append({"role": "user", "content": RESUME_QUESTION})
+
+ resumed_sandbox = await client.resume(frozen_session_state)
+ try:
+ async with resumed_sandbox:
+ resumed_result = Runner.run_streamed(
+ agent,
+ conversation,
+ max_turns=20,
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=resumed_sandbox),
+ tracing_disabled=True,
+ workflow_name="Sandbox resume example",
+ ),
+ )
+ conversation = await run_step(resumed_result)
+ finally:
+ await client.delete(resumed_sandbox)
+ finally:
+ await client.delete(sandbox)
+
+
+if __name__ == "__main__":
+ load_env_defaults(DEMO_DIR / ".env")
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--model",
+ default="gpt-5.4-mini",
+ help="Model name to use.",
+ )
+ parser.add_argument(
+ "--question",
+ default=DEFAULT_QUESTION,
+ help="Prompt to send to the agent.",
+ )
+ parser.add_argument(
+ "--docker",
+ action="store_true",
+ help="Run this example in Docker instead of Unix-local.",
+ )
+ parser.add_argument(
+ "--image",
+ default=DEFAULT_SANDBOX_IMAGE,
+ help="Docker image to use when --docker is set.",
+ )
+ args = parser.parse_args()
+
+ asyncio.run(main(args.model, args.question, args.docker, args.image))
diff --git a/examples/sandbox/tutorials/vision_website_clone/README.md b/examples/sandbox/tutorials/vision_website_clone/README.md
new file mode 100644
index 00000000..b6535fce
--- /dev/null
+++ b/examples/sandbox/tutorials/vision_website_clone/README.md
@@ -0,0 +1,52 @@
+# Vision UI reproduction
+
+## Goal
+
+Use the sandbox `view_image` tool to inspect a reference app screenshot, then
+reproduce the visible screen as a static HTML/CSS artifact. This is a narrow UI
+repro target for vision and screenshot-debugging; it is not a web-app scaffold.
+
+This demo is intentionally file-only: no FastAPI, no exposed port, and no local
+browser server. The agent calls `view_image`, lazy-loads the `playwright` skill,
+writes the site under `output/site/`, captures browser screenshots for visual
+revision, and the host copies the generated site plus the visual-review
+artifacts back to this example's `output/` directory.
+
+## Setup
+
+Run the Unix-local example from the repository root:
+
+```bash
+uv run python examples/sandbox/tutorials/vision_website_clone/main.py
+```
+
+To run the same manifest in Docker, build the shared tutorial image once and pass
+`--docker`:
+
+```bash
+docker build -t sandbox-tutorials:latest -f examples/sandbox/tutorials/Dockerfile .
+uv run python examples/sandbox/tutorials/vision_website_clone/main.py --docker
+```
+
+## Expected artifact
+
+- `output/index.html`
+- `output/styles.css`
+- `output/screenshots/draft-1.png`
+- `output/screenshots/draft-2.png`
+- `output/visual-notes.md`
+
+Open `output/index.html` locally after the run to inspect the generated clone.
+Open the copied draft screenshots to inspect the agent's visual-debug loop.
+
+## Demo shape
+
+- Inputs: one checked-in PNG reference screenshot mounted under `reference/`.
+- Runtime primitives: sandbox-local shell/edit tools, `view_image`, and the
+ lazy-loaded `playwright` skill.
+- Required vision call: `view_image("reference/reference-site.png")`.
+- Required debug loop: capture `output/screenshots/draft-1.png`, view it with
+ `view_image`, revise, then repeat with `output/screenshots/draft-2.png`.
+- Artifact path: the sandbox agent writes `output/site/`, `output/screenshots/`,
+ and `output/visual-notes.md`; `main.py` copies the site files and review
+ artifacts to this example's `output/`.
diff --git a/examples/sandbox/tutorials/vision_website_clone/__init__.py b/examples/sandbox/tutorials/vision_website_clone/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/examples/sandbox/tutorials/vision_website_clone/__init__.py
@@ -0,0 +1 @@
+
diff --git a/examples/sandbox/tutorials/vision_website_clone/main.py b/examples/sandbox/tutorials/vision_website_clone/main.py
new file mode 100644
index 00000000..e74d470c
--- /dev/null
+++ b/examples/sandbox/tutorials/vision_website_clone/main.py
@@ -0,0 +1,240 @@
+"""
+Clone a reference app screenshot as static HTML/CSS with the sandbox filesystem tools.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+from pathlib import Path
+from textwrap import dedent
+
+from agents import ModelSettings, Runner
+from agents.run import RunConfig
+from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig, WorkspaceReadNotFoundError
+from agents.sandbox.capabilities import (
+ Filesystem,
+ LocalDirLazySkillSource,
+ Shell,
+ Skills,
+)
+from agents.sandbox.entries import Dir, File, LocalDir, LocalFile
+from agents.sandbox.session import BaseSandboxSession
+
+if __package__ is None or __package__ == "":
+ sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
+
+from examples.sandbox.tutorials.misc import (
+ DEFAULT_SANDBOX_IMAGE,
+ console,
+ create_sandbox_client_and_session,
+ load_env_defaults,
+ print_event,
+)
+
+DEMO_DIR = Path(__file__).resolve().parent
+REFERENCE_IMAGE = DEMO_DIR / "reference-site.png"
+SKILLS_SOURCE_DIR = DEMO_DIR / "skills"
+SANDBOX_SITE_DIR = Path("output") / "site"
+REMOTE_REVIEW_ARTIFACTS = (
+ Path("output") / "screenshots" / "draft-1.png",
+ Path("output") / "screenshots" / "draft-2.png",
+ Path("output") / "visual-notes.md",
+)
+DEFAULT_MODEL = "gpt-5.4-mini"
+DEFAULT_QUESTION = (
+ "Inspect the reference screenshot and build a static HTML/CSS reproduction of the "
+ "screen. Write output/site/index.html and output/site/styles.css, then capture "
+ "browser screenshots, inspect them, and revise the site."
+)
+AGENTS_MD = dedent(
+ """\
+ # Vision UI Reproduction Instructions
+
+ Create a static HTML/CSS reproduction of the provided reference screenshot.
+
+ Build only the single screen shown in the reference.
+
+ ## Required workflow (must do)
+
+ - First call `view_image` on `reference/reference-site.png`.
+ - Before writing code, write `output/visual-notes.md` with brief layout + typography notes.
+ - Write the site to `output/site/index.html` and `output/site/styles.css`.
+ - Before taking screenshots, call `load_skill("playwright")` and read `skills/playwright/SKILL.md`.
+ - Capture `output/screenshots/draft-1.png`, inspect it, revise, then capture `output/screenshots/draft-2.png`.
+ - Do not finish without the screenshots.
+ """
+)
+
+
+def build_manifest() -> Manifest:
+ return Manifest(
+ entries={
+ "AGENTS.md": File(content=AGENTS_MD.encode("utf-8")),
+ "reference": Dir(
+ children={
+ "reference-site.png": LocalFile(src=REFERENCE_IMAGE),
+ },
+ description="Reference app screenshot to clone.",
+ ),
+ "output": Dir(description="Write generated website files here."),
+ }
+ )
+
+
+def build_agent(model: str) -> SandboxAgent:
+ return SandboxAgent(
+ name="Vision Website Clone Builder",
+ model=model,
+ instructions=AGENTS_MD,
+ capabilities=[
+ Shell(),
+ Filesystem(),
+ Skills(
+ lazy_from=LocalDirLazySkillSource(source=LocalDir(src=SKILLS_SOURCE_DIR)),
+ skills_path="skills",
+ ),
+ ],
+ model_settings=ModelSettings(tool_choice="required"),
+ )
+
+
+async def copy_site_output_dir(
+ *,
+ session: BaseSandboxSession,
+ output_dir: Path,
+) -> list[Path]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ remote_site_dir = session.normalize_path(SANDBOX_SITE_DIR)
+ pending_dirs = [remote_site_dir]
+ copied_files: list[Path] = []
+
+ while pending_dirs:
+ current_dir = pending_dirs.pop()
+ for entry in await session.ls(current_dir):
+ entry_path = Path(entry.path)
+ if entry.is_dir():
+ pending_dirs.append(entry_path)
+ continue
+
+ relative_path = entry_path.relative_to(remote_site_dir)
+ local_path = output_dir / relative_path
+ local_path.parent.mkdir(parents=True, exist_ok=True)
+
+ handle = await session.read(entry_path)
+ try:
+ payload = handle.read()
+ finally:
+ handle.close()
+
+ if isinstance(payload, str):
+ local_path.write_text(payload, encoding="utf-8")
+ else:
+ local_path.write_bytes(bytes(payload))
+ copied_files.append(local_path)
+
+ return copied_files
+
+
+async def copy_review_artifacts(
+ *,
+ session: BaseSandboxSession,
+ output_dir: Path,
+ remote_artifacts: tuple[Path, ...] = REMOTE_REVIEW_ARTIFACTS,
+) -> list[Path]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ copied_files: list[Path] = []
+
+ for remote_artifact in remote_artifacts:
+ remote_path = session.normalize_path(remote_artifact)
+ relative_artifact = remote_artifact.relative_to(Path("output"))
+ local_path = output_dir / relative_artifact
+ local_path.parent.mkdir(parents=True, exist_ok=True)
+
+ try:
+ handle = await session.read(remote_path)
+ except WorkspaceReadNotFoundError:
+ continue
+ try:
+ payload = handle.read()
+ finally:
+ handle.close()
+
+ if isinstance(payload, str):
+ local_path.write_text(payload, encoding="utf-8")
+ else:
+ local_path.write_bytes(bytes(payload))
+ copied_files.append(local_path)
+
+ return copied_files
+
+
+async def main(model: str, question: str, use_docker: bool, image: str, output_dir: Path) -> None:
+ client, sandbox = await create_sandbox_client_and_session(
+ manifest=build_manifest(),
+ use_docker=use_docker,
+ image=image,
+ )
+ try:
+ async with sandbox:
+ result = Runner.run_streamed(
+ build_agent(model),
+ [{"role": "user", "content": question}],
+ max_turns=30,
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ tracing_disabled=True,
+ workflow_name="Vision Website Clone example",
+ ),
+ )
+ async for event in result.stream_events():
+ print_event(event)
+ if result.final_output is None:
+ raise RuntimeError("Vision Website Clone Builder returned no final message.")
+ print_event(str(result.final_output).strip())
+ copied_files = await copy_site_output_dir(session=sandbox, output_dir=output_dir)
+ copied_review_files = await copy_review_artifacts(
+ session=sandbox,
+ output_dir=output_dir,
+ )
+ finally:
+ await client.delete(sandbox)
+
+ expected_files = {output_dir / "index.html", output_dir / "styles.css"}
+ if not expected_files <= set(copied_files):
+ raise RuntimeError(
+ "Vision Website Clone Builder must write output/site/index.html and "
+ "output/site/styles.css."
+ )
+
+ console.print(f"[green]Copied static site to {output_dir / 'index.html'}[/green]")
+ for path in copied_review_files:
+ console.print(f"[green]Copied review artifact to {path}[/green]")
+
+
+if __name__ == "__main__":
+ load_env_defaults(DEMO_DIR / ".env")
+
+ parser = argparse.ArgumentParser()
+ 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(
+ "--docker",
+ action="store_true",
+ help="Run this example in Docker instead of Unix-local.",
+ )
+ parser.add_argument(
+ "--image",
+ default=DEFAULT_SANDBOX_IMAGE,
+ help="Docker image to use when --docker is set.",
+ )
+ parser.add_argument(
+ "--output-dir",
+ type=Path,
+ default=DEMO_DIR / "output",
+ help="Directory for copied website files.",
+ )
+ args = parser.parse_args()
+
+ asyncio.run(main(args.model, args.question, args.docker, args.image, args.output_dir))
diff --git a/examples/sandbox/tutorials/vision_website_clone/reference-site.png b/examples/sandbox/tutorials/vision_website_clone/reference-site.png
new file mode 100644
index 00000000..8575258d
Binary files /dev/null and b/examples/sandbox/tutorials/vision_website_clone/reference-site.png differ
diff --git a/examples/sandbox/tutorials/vision_website_clone/skills/playwright/SKILL.md b/examples/sandbox/tutorials/vision_website_clone/skills/playwright/SKILL.md
new file mode 100644
index 00000000..e9129609
--- /dev/null
+++ b/examples/sandbox/tutorials/vision_website_clone/skills/playwright/SKILL.md
@@ -0,0 +1,24 @@
+---
+name: "playwright"
+description: "Use when the task requires capturing or automating a real browser from the terminal."
+---
+
+# Playwright
+
+Use Playwright to capture the static site directly. Do not start a server for
+this example.
+
+```sh
+mkdir -p output/screenshots output/playwright/.tmp
+export TMPDIR="$PWD/output/playwright/.tmp"
+export TEMP="$TMPDIR"
+export TMP="$TMPDIR"
+npx --yes --package playwright@1.50.0 playwright install chromium
+npx --yes --package playwright@1.50.0 playwright screenshot \
+ --browser=chromium \
+ --viewport-size=2048,1152 \
+ "file://$PWD/output/site/index.html" \
+ output/screenshots/draft-1.png
+```
+
+Change the final path to `output/screenshots/draft-2.png` for the second pass.
diff --git a/examples/sandbox/unix_local_pty.py b/examples/sandbox/unix_local_pty.py
new file mode 100644
index 00000000..5918f2d8
--- /dev/null
+++ b/examples/sandbox/unix_local_pty.py
@@ -0,0 +1,165 @@
+"""Show how a sandbox agent can keep using the same interactive Python process.
+
+This example uses the Unix-local sandbox with the `Shell` capability. The task only asks
+for a stateful interaction, but the streamed output shows the actual shell tools the agent
+chooses, including the follow-up writes that keep the same process alive.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+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.capabilities import Shell
+from agents.sandbox.entries import File
+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 tool_call_name
+
+DEFAULT_MODEL = "gpt-5.4"
+DEFAULT_QUESTION = (
+ "Start an interactive Python session. 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:
+ return Manifest(
+ entries={
+ "README.md": File(
+ content=(
+ b"# Unix-local PTY Agent Example\n\n"
+ b"This workspace is used by examples/sandbox/unix_local_pty.py.\n"
+ )
+ ),
+ }
+ )
+
+
+def _build_agent(model: str) -> SandboxAgent:
+ return SandboxAgent(
+ name="Unix-local PTY Demo",
+ model=model,
+ instructions=(
+ "Complete the task by inspecting and 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_manifest(),
+ capabilities=[Shell()],
+ model_settings=ModelSettings(tool_choice="required"),
+ )
+
+
+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 main(model: str, question: str) -> None:
+ agent = _build_agent(model)
+ client = UnixLocalSandboxClient()
+ sandbox = await client.create(manifest=agent.default_manifest)
+
+ try:
+ async with sandbox:
+ result = Runner.run_streamed(
+ agent,
+ question,
+ run_config=RunConfig(
+ sandbox=SandboxRunConfig(session=sandbox),
+ tracing_disabled=True,
+ workflow_name="Unix-local PTY 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":
+ tool_name = tool_call_name(raw_item)
+ call_id = _raw_item_call_id(raw_item)
+ if call_id is not None and tool_name:
+ tool_names_by_call_id[call_id] = tool_name
+ if tool_name:
+ banner = f"{banner} {tool_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)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description=(
+ "Run a Unix-local sandbox agent that demonstrates PTY interaction through the "
+ "shell capability."
+ )
+ )
+ 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.",
+ )
+ args = parser.parse_args()
+
+ asyncio.run(main(args.model, args.question))
diff --git a/examples/sandbox/unix_local_runner.py b/examples/sandbox/unix_local_runner.py
new file mode 100644
index 00000000..74cce3bc
--- /dev/null
+++ b/examples/sandbox/unix_local_runner.py
@@ -0,0 +1,110 @@
+"""
+Start here if you want the simplest Unix-local sandbox example.
+
+This file mirrors the Docker example, but the sandbox runs as a temporary local
+workspace on macOS or Linux instead of inside a Docker container.
+"""
+
+import argparse
+import asyncio
+import sys
+from pathlib import Path
+
+from openai.types.responses import ResponseTextDeltaEvent
+
+from agents import 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 this renewal packet. Summarize the customer's situation, the likely blockers, "
+ "and the next two actions an account team should take."
+)
+
+
+async def main(model: str, question: str, stream: bool) -> None:
+ # The manifest is the file tree that will be materialized into the sandbox workspace.
+ manifest = text_manifest(
+ {
+ "account_brief.md": (
+ "# Northwind Health\n\n"
+ "- Segment: Mid-market healthcare analytics provider.\n"
+ "- Annual contract value: $148,000.\n"
+ "- Renewal date: 2026-04-15.\n"
+ "- Executive sponsor: Director of Data Operations.\n"
+ ),
+ "renewal_request.md": (
+ "# Renewal request\n\n"
+ "Northwind requested a 12 percent discount in exchange for a two-year renewal. "
+ "They also want a 45-day implementation timeline for a new reporting workspace.\n"
+ ),
+ "usage_notes.md": (
+ "# Usage notes\n\n"
+ "- Weekly active users increased 18 percent over the last quarter.\n"
+ "- API traffic is stable.\n"
+ "- The customer still has one unresolved SSO configuration issue from onboarding.\n"
+ ),
+ "implementation_risks.md": (
+ "# Delivery risks\n\n"
+ "- Security questionnaire for the new reporting workspace is not complete.\n"
+ "- Customer procurement requires final legal language by April 1.\n"
+ ),
+ }
+ )
+
+ # The sandbox agent sees the manifest as its workspace and uses one shared shell tool
+ # to inspect the files before answering.
+ agent = SandboxAgent(
+ name="Renewal Packet Analyst",
+ model=model,
+ instructions=(
+ "You review renewal packets for an account team. Inspect the packet before answering. "
+ "Keep the response concise, business-focused, and cite the file names that support "
+ "each conclusion. "
+ "If a conclusion depends on a file, mention that file by name. Do not invent numbers "
+ "or statuses that are not present in the workspace."
+ ),
+ default_manifest=manifest,
+ capabilities=[WorkspaceShellCapability()],
+ )
+
+ # With Unix-local sandboxes, the runner creates and cleans up the temporary workspace for us.
+ run_config = RunConfig(
+ sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
+ workflow_name="Unix local sandbox review",
+ )
+
+ if not stream:
+ result = await Runner.run(agent, question, run_config=run_config)
+ print(result.final_output)
+ return
+
+ # The streaming path prints text deltas as they arrive so the example behaves like a demo.
+ 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("--stream", action="store_true", default=False, help="Stream the response.")
+ args = parser.parse_args()
+
+ asyncio.run(main(args.model, args.question, args.stream))
diff --git a/examples/tools/codex.py b/examples/tools/codex.py
index 97a52304..bd5d5089 100644
--- a/examples/tools/codex.py
+++ b/examples/tools/codex.py
@@ -52,7 +52,7 @@ async def on_codex_stream(payload: CodexToolStreamEvent) -> None:
log(f"codex stream error: {event.message}")
return
- if not isinstance(event, (ItemStartedEvent, ItemUpdatedEvent, ItemCompletedEvent)):
+ if not isinstance(event, ItemStartedEvent | ItemUpdatedEvent | ItemCompletedEvent):
return
item = event.item
diff --git a/examples/tools/computer_use.py b/examples/tools/computer_use.py
index 1935ec1e..b974dbfe 100644
--- a/examples/tools/computer_use.py
+++ b/examples/tools/computer_use.py
@@ -5,7 +5,7 @@
import asyncio
import base64
import sys
-from typing import Any, Literal, Union
+from typing import Any, Literal
from playwright.async_api import Browser, Page, Playwright, async_playwright
@@ -59,9 +59,9 @@ class LocalPlaywrightComputer(AsyncComputer):
"""A computer, implemented using a local Playwright browser."""
def __init__(self):
- self._playwright: Union[Playwright, None] = None
- self._browser: Union[Browser, None] = None
- self._page: Union[Page, None] = None
+ self._playwright: Playwright | None = None
+ self._browser: Browser | None = None
+ self._page: Page | None = None
async def _get_browser_and_page(self) -> tuple[Browser, Page]:
width, height = self.dimensions
diff --git a/examples/voice/streamed/my_workflow.py b/examples/voice/streamed/my_workflow.py
index 76b69e1a..2e0bf1c8 100644
--- a/examples/voice/streamed/my_workflow.py
+++ b/examples/voice/streamed/my_workflow.py
@@ -1,6 +1,5 @@
import random
-from collections.abc import AsyncIterator
-from typing import Callable
+from collections.abc import AsyncIterator, Callable
from agents import Agent, Runner, TResponseInputItem, function_tool
from agents.extensions.handoff_prompt import prompt_with_handoff_instructions
diff --git a/mkdocs.yml b/mkdocs.yml
index 057472b8..fdf99b88 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -53,121 +53,146 @@ plugins:
- Quickstart: quickstart.md
- Configuration: config.md
- Documentation:
- - agents.md
+ - Agents: agents.md
+ - Sandbox agents:
+ - Quickstart: sandbox_agents.md
+ - Concepts: sandbox/guide.md
+ - Sandbox clients: sandbox/clients.md
+ - Agent memory: sandbox/memory.md
- Models: models/index.md
- - tools.md
- - guardrails.md
- - running_agents.md
- - streaming.md
- - multi_agent.md
- - handoffs.md
- - results.md
- - human_in_the_loop.md
+ - Tools: tools.md
+ - Guardrails: guardrails.md
+ - Running agents: running_agents.md
+ - Streaming: streaming.md
+ - Agent orchestration: multi_agent.md
+ - Handoffs: handoffs.md
+ - Results: results.md
+ - Human-in-the-loop: human_in_the_loop.md
- Sessions:
- - sessions/index.md
- - sessions/sqlalchemy_session.md
- - sessions/advanced_sqlite_session.md
- - sessions/encrypted_session.md
- - context.md
- - usage.md
- - mcp.md
- - tracing.md
+ - Overview: sessions/index.md
+ - SQLAlchemy session: sessions/sqlalchemy_session.md
+ - Advanced SQLite session: sessions/advanced_sqlite_session.md
+ - Encrypted session: sessions/encrypted_session.md
+ - Context management: context.md
+ - Usage: usage.md
+ - Model context protocol (MCP): mcp.md
+ - Tracing: tracing.md
- Realtime agents:
- - realtime/quickstart.md
- - realtime/transport.md
- - realtime/guide.md
+ - Quickstart: realtime/quickstart.md
+ - Transport: realtime/transport.md
+ - Guide: realtime/guide.md
- Voice agents:
- - voice/quickstart.md
- - voice/pipeline.md
- - voice/tracing.md
- - visualization.md
- - repl.md
+ - Quickstart: voice/quickstart.md
+ - Pipeline: voice/pipeline.md
+ - Tracing: voice/tracing.md
+ - Agent visualization: visualization.md
+ - REPL utility: repl.md
- Examples: examples.md
- - release.md
+ - Release process/changelog: release.md
- API Reference:
- Agents:
- - ref/index.md
- - ref/agent.md
- - ref/run.md
- - ref/run_config.md
- - ref/run_state.md
- - ref/responses_websocket_session.md
- - ref/run_error_handlers.md
- - ref/memory.md
- - ref/repl.md
- - ref/tool.md
- - ref/tool_context.md
- - ref/result.md
- - ref/stream_events.md
- - ref/handoffs.md
- - ref/lifecycle.md
- - ref/items.md
- - ref/run_context.md
- - ref/usage.md
- - ref/exceptions.md
- - ref/guardrail.md
- - ref/prompts.md
- - ref/model_settings.md
- - ref/strict_schema.md
- - ref/tool_guardrails.md
- - ref/computer.md
- - ref/agent_output.md
- - ref/function_schema.md
- - ref/models/interface.md
- - ref/models/openai_chatcompletions.md
- - ref/models/openai_responses.md
- - ref/models/openai_provider.md
- - ref/models/multi_provider.md
- - ref/mcp/server.md
- - ref/mcp/util.md
- - ref/mcp/manager.md
+ - Agents module: ref/index.md
+ - Agent: ref/agent.md
+ - Runner: ref/run.md
+ - Run config: ref/run_config.md
+ - Run state: ref/run_state.md
+ - Sandbox:
+ - Overview: ref/sandbox.md
+ - SandboxAgent: ref/sandbox/sandbox_agent.md
+ - Manifest: ref/sandbox/manifest.md
+ - Permissions: ref/sandbox/permissions.md
+ - SnapshotSpec: ref/sandbox/snapshot.md
+ - Workspace entries: ref/sandbox/entries.md
+ - Capabilities:
+ - Capabilities: ref/sandbox/capabilities/capabilities.md
+ - Capability: ref/sandbox/capabilities/capability.md
+ - Filesystem: ref/sandbox/capabilities/filesystem.md
+ - Shell: ref/sandbox/capabilities/shell.md
+ - Memory: ref/sandbox/capabilities/memory.md
+ - Skills: ref/sandbox/capabilities/skills.md
+ - Compaction: ref/sandbox/capabilities/compaction.md
+ - Sandbox clients: ref/sandbox/session/sandbox_client.md
+ - SandboxSession: ref/sandbox/session/sandbox_session.md
+ - SandboxSessionState: ref/sandbox/session/sandbox_session_state.md
+ - Unix local sandbox: ref/sandbox/sandboxes/unix_local.md
+ - Docker sandbox: ref/sandbox/sandboxes/docker.md
+ - Responses WebSocket session: ref/responses_websocket_session.md
+ - Run error handlers: ref/run_error_handlers.md
+ - Memory: ref/memory.md
+ - REPL: ref/repl.md
+ - Tools: ref/tool.md
+ - Tool context: ref/tool_context.md
+ - Results: ref/result.md
+ - Streaming events: ref/stream_events.md
+ - Handoffs: ref/handoffs.md
+ - Lifecycle: ref/lifecycle.md
+ - Items: ref/items.md
+ - Run context: ref/run_context.md
+ - Usage: ref/usage.md
+ - Exceptions: ref/exceptions.md
+ - Guardrails: ref/guardrail.md
+ - Prompts: ref/prompts.md
+ - Model settings: ref/model_settings.md
+ - Strict schema: ref/strict_schema.md
+ - Tool guardrails: ref/tool_guardrails.md
+ - Computer: ref/computer.md
+ - Agent output: ref/agent_output.md
+ - Function schema: ref/function_schema.md
+ - Model interface: ref/models/interface.md
+ - OpenAI Chat Completions model: ref/models/openai_chatcompletions.md
+ - OpenAI Responses model: ref/models/openai_responses.md
+ - OpenAI provider: ref/models/openai_provider.md
+ - Multi provider: ref/models/multi_provider.md
+ - MCP servers: ref/mcp/server.md
+ - MCP util: ref/mcp/util.md
+ - MCP manager: ref/mcp/manager.md
- Tracing:
- - ref/tracing/index.md
- - ref/tracing/create.md
- - ref/tracing/traces.md
- - ref/tracing/spans.md
- - ref/tracing/processor_interface.md
- - ref/tracing/processors.md
- - ref/tracing/scope.md
- - ref/tracing/setup.md
- - ref/tracing/span_data.md
- - ref/tracing/util.md
+ - Tracing module: ref/tracing/index.md
+ - Creating traces/spans: ref/tracing/create.md
+ - Traces: ref/tracing/traces.md
+ - Spans: ref/tracing/spans.md
+ - Processor interface: ref/tracing/processor_interface.md
+ - Processors: ref/tracing/processors.md
+ - Scope: ref/tracing/scope.md
+ - Setup: ref/tracing/setup.md
+ - Span data: ref/tracing/span_data.md
+ - Util: ref/tracing/util.md
- Realtime:
- - ref/realtime/agent.md
- - ref/realtime/runner.md
- - ref/realtime/session.md
- - ref/realtime/events.md
- - ref/realtime/config.md
- - ref/realtime/model.md
+ - RealtimeAgent: ref/realtime/agent.md
+ - RealtimeRunner: ref/realtime/runner.md
+ - RealtimeSession: ref/realtime/session.md
+ - Events: ref/realtime/events.md
+ - Configuration: ref/realtime/config.md
+ - Model: ref/realtime/model.md
- Voice:
- - ref/voice/pipeline.md
- - ref/voice/workflow.md
- - ref/voice/input.md
- - ref/voice/result.md
- - ref/voice/pipeline_config.md
- - ref/voice/events.md
- - ref/voice/exceptions.md
- - ref/voice/model.md
- - ref/voice/utils.md
- - ref/voice/models/openai_provider.md
- - ref/voice/models/openai_stt.md
- - ref/voice/models/openai_tts.md
+ - Pipeline: ref/voice/pipeline.md
+ - Workflow: ref/voice/workflow.md
+ - Input: ref/voice/input.md
+ - Result: ref/voice/result.md
+ - Pipeline config: ref/voice/pipeline_config.md
+ - Events: ref/voice/events.md
+ - Exceptions: ref/voice/exceptions.md
+ - Model: ref/voice/model.md
+ - Utils: ref/voice/utils.md
+ - OpenAI voice model provider: ref/voice/models/openai_provider.md
+ - OpenAI STT: ref/voice/models/openai_stt.md
+ - OpenAI TTS: ref/voice/models/openai_tts.md
- Extensions:
- - ref/extensions/handoff_filters.md
- - ref/extensions/handoff_prompt.md
+ - Handoff filters: ref/extensions/handoff_filters.md
+ - Handoff prompt: ref/extensions/handoff_prompt.md
- Third-party adapters:
- Any-LLM model: ref/extensions/models/any_llm_model.md
- Any-LLM provider: ref/extensions/models/any_llm_provider.md
- LiteLLM model: ref/extensions/models/litellm_model.md
- LiteLLM provider: ref/extensions/models/litellm_provider.md
- - ref/extensions/tool_output_trimmer.md
- - ref/extensions/memory/sqlalchemy_session.md
- - ref/extensions/memory/async_sqlite_session.md
- - ref/extensions/memory/redis_session.md
- - ref/extensions/memory/dapr_session.md
- - ref/extensions/memory/encrypt_session.md
- - ref/extensions/memory/advanced_sqlite_session.md
+ - Tool output trimmer: ref/extensions/tool_output_trimmer.md
+ - SQLAlchemySession: ref/extensions/memory/sqlalchemy_session.md
+ - Async SQLite session: ref/extensions/memory/async_sqlite_session.md
+ - RedisSession: ref/extensions/memory/redis_session.md
+ - DaprSession: ref/extensions/memory/dapr_session.md
+ - EncryptedSession: ref/extensions/memory/encrypt_session.md
+ - AdvancedSQLiteSession: ref/extensions/memory/advanced_sqlite_session.md
- locale: ja
name: 日本語
build: true
@@ -177,6 +202,7 @@ plugins:
- config.md
- ドキュメント:
- agents.md
+ - sandbox_agents.md
- モデル: models/index.md
- tools.md
- guardrails.md
@@ -215,6 +241,7 @@ plugins:
- config.md
- 문서:
- agents.md
+ - sandbox_agents.md
- 모델: models/index.md
- tools.md
- guardrails.md
@@ -253,6 +280,7 @@ plugins:
- config.md
- 文档:
- agents.md
+ - sandbox_agents.md
- 模型: models/index.md
- tools.md
- guardrails.md
diff --git a/pyproject.toml b/pyproject.toml
index 0708cfc7..74585752 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,7 @@ dependencies = [
"typing-extensions>=4.12.2, <5",
"requests>=2.0, <3",
"types-requests>=2.0, <3",
+ "websockets>=15.0, <16",
"mcp>=1.19.0, <2; python_version >= '3.10'",
]
classifiers = [
@@ -36,46 +37,59 @@ Repository = "https://github.com/openai/openai-agents-python"
[project.optional-dependencies]
voice = ["numpy>=2.2.0, <3; python_version>='3.10'", "websockets>=15.0, <16"]
viz = ["graphviz>=0.17"]
-litellm = ["litellm>=1.81.0, <=1.82.6"]
+litellm = ["litellm>=1.83.0"]
any-llm = ["any-llm-sdk>=1.11.0, <2; python_version >= '3.11'"]
realtime = ["websockets>=15.0, <16"]
sqlalchemy = ["SQLAlchemy>=2.0", "asyncpg>=0.29.0"]
encrypt = ["cryptography>=45.0, <46"]
redis = ["redis>=7"]
dapr = ["dapr>=1.16.0", "grpcio>=1.60.0"]
+docker = ["docker>=6.1"]
+blaxel = ["blaxel>=0.2.50", "aiohttp>=3.12,<4"]
+daytona = ["daytona>=0.155.0"]
+cloudflare = ["aiohttp>=3.12,<4"]
+e2b = ["e2b==2.20.0", "e2b-code-interpreter==2.4.1"]
+modal = ["modal==1.3.5"]
+runloop = ["runloop_api_client>=1.16.0,<2.0.0"]
+vercel = ["vercel>=0.5.6,<0.6"]
+s3 = ["boto3>=1.34"]
+temporal = [
+ "temporalio==1.25.0",
+ "textual>=8.2.3,<8.3",
+]
[dependency-groups]
dev = [
- "mypy",
- "ruff==0.9.2",
- "pytest",
- "pytest-asyncio",
- "pytest-mock>=3.14.0",
- "pytest-xdist",
- "rich>=13.1.0, <14",
- "mkdocs>=1.6.0",
- "mkdocs-material>=9.6.0",
- "mkdocstrings[python]>=0.28.0",
- "mkdocs-static-i18n",
- "coverage>=7.6.12",
- "playwright==1.50.0",
- "inline-snapshot>=0.20.7",
- "pynput",
- "types-pynput",
- "sounddevice",
- "textual",
- "websockets",
- "graphviz",
- "mkdocs-static-i18n>=1.3.0",
- "eval-type-backport>=0.2.2",
- "fastapi >= 0.110.0, <1",
- "aiosqlite>=0.21.0",
- "cryptography>=45.0, <46",
- "fakeredis>=2.31.3",
- "dapr>=1.14.0",
- "grpcio>=1.60.0",
- "testcontainers==4.12.0", # pinned to 4.12.0 because 4.13.0 has a warning bug in wait_for_logs, see https://github.com/testcontainers/testcontainers-python/issues/874
- "pyright==1.1.408",
+ "mypy",
+ "ruff==0.9.2",
+ "pytest",
+ "pytest-asyncio",
+ "pytest-mock>=3.14.0",
+ "pytest-xdist",
+ "rich>=13.1.0, <15",
+ "mkdocs>=1.6.0",
+ "mkdocs-material>=9.6.0",
+ "mkdocstrings[python]>=0.28.0",
+ "mkdocs-static-i18n",
+ "coverage>=7.6.12",
+ "playwright==1.50.0",
+ "inline-snapshot>=0.20.7",
+ "pynput",
+ "types-pynput",
+ "sounddevice",
+ "textual",
+ "websockets",
+ "graphviz",
+ "mkdocs-static-i18n>=1.3.0",
+ "eval-type-backport>=0.2.2",
+ "fastapi >= 0.110.0, <1",
+ "aiosqlite>=0.21.0",
+ "cryptography>=45.0, <46",
+ "fakeredis>=2.31.3",
+ "dapr>=1.14.0",
+ "grpcio>=1.60.0",
+ "testcontainers==4.12.0", # pinned to 4.12.0 because 4.13.0 has a warning bug in wait_for_logs, see https://github.com/testcontainers/testcontainers-python/issues/874
+ "pyright==1.1.408",
]
[tool.uv.workspace]
@@ -94,17 +108,17 @@ packages = ["src/agents"]
[tool.ruff]
line-length = 100
-target-version = "py39"
+target-version = "py310"
[tool.ruff.lint]
select = [
- "E", # pycodestyle errors
- "W", # pycodestyle warnings
- "F", # pyflakes
- "I", # isort
- "B", # flake8-bugbear
- "C4", # flake8-comprehensions
- "UP", # pyupgrade
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "UP", # pyupgrade
]
isort = { combine-as-imports = true, known-first-party = ["agents"] }
@@ -124,19 +138,57 @@ disallow_untyped_calls = false
module = "sounddevice.*"
ignore_missing_imports = true
+[[tool.mypy.overrides]]
+module = ["modal", "modal.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["e2b", "e2b.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["daytona", "daytona.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["runloop_api_client", "runloop_api_client.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["blaxel", "blaxel.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["vercel", "vercel.*"]
+ignore_missing_imports = true
+
[tool.coverage.run]
source = ["src/agents"]
-omit = ["tests/*"]
+omit = [
+ "tests/*",
+ "src/agents/sandbox/sandboxes/*.py",
+ "src/agents/sandbox/task_context.py",
+ "src/agents/sandbox/task_runtime.py",
+ "src/agents/sandbox/materialization.py",
+ "src/agents/sandbox/entries/artifacts.py",
+ "src/agents/sandbox/entries/mounts/*.py",
+ "src/agents/sandbox/util/checksums.py",
+ "src/agents/sandbox/util/deep_merge.py",
+ "src/agents/sandbox/util/github.py",
+ "src/agents/sandbox/util/iterator_io.py",
+ "src/agents/sandbox/util/parse_utils.py",
+ "src/agents/sandbox/util/tar_utils.py",
+]
[tool.coverage.report]
show_missing = true
sort = "-Cover"
exclude_also = [
- # This is only executed while typechecking
- "if TYPE_CHECKING:",
- "@abc.abstractmethod",
- "raise NotImplementedError",
- "logger.debug",
+ # This is only executed while typechecking
+ "if TYPE_CHECKING:",
+ "@abc.abstractmethod",
+ "raise NotImplementedError",
+ "logger.debug",
]
[tool.pytest.ini_options]
@@ -144,12 +196,12 @@ asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
testpaths = ["tests"]
filterwarnings = [
- # This is a warning that is expected to happen: we have an async filter that raises an exception
- "ignore:coroutine 'test_async_input_filter_fails..invalid_input_filter' was never awaited:RuntimeWarning",
+ # This is a warning that is expected to happen: we have an async filter that raises an exception
+ "ignore:coroutine 'test_async_input_filter_fails..invalid_input_filter' was never awaited:RuntimeWarning",
]
markers = [
- "allow_call_model_methods: mark test as allowing calls to real model implementations",
- "serial: mark test as requiring serial execution",
+ "allow_call_model_methods: mark test as allowing calls to real model implementations",
+ "serial: mark test as requiring serial execution",
]
[tool.inline-snapshot]
diff --git a/pyrightconfig.json b/pyrightconfig.json
index 5ed52516..850189d5 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -1,5 +1,6 @@
{
"include": ["src", "tests"],
+ "exclude": [],
"extraPaths": ["."],
"pythonVersion": "3.10",
"typeCheckingMode": "basic",
diff --git a/src/agents/__init__.py b/src/agents/__init__.py
index bb2f23ac..932e8cd6 100644
--- a/src/agents/__init__.py
+++ b/src/agents/__init__.py
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Literal
from openai import AsyncOpenAI
-from . import _config
+from . import _config, sandbox
from .agent import (
Agent,
AgentBase,
@@ -79,6 +79,7 @@ from .memory import (
from .model_settings import ModelSettings
from .models.interface import Model, ModelProvider, ModelTracing
from .models.multi_provider import MultiProvider
+from .models.openai_agent_registration import OpenAIAgentRegistrationConfig
from .models.openai_chatcompletions import OpenAIChatCompletionsModel
from .models.openai_provider import OpenAIProvider
from .models.openai_responses import OpenAIResponsesModel, OpenAIResponsesWSModel
@@ -124,6 +125,7 @@ from .tool import (
CodeInterpreterTool,
ComputerProvider,
ComputerTool,
+ CustomTool,
FileSearchTool,
FunctionTool,
FunctionToolResult,
@@ -282,6 +284,25 @@ def set_default_openai_responses_transport(transport: Literal["http", "websocket
_config.set_default_openai_responses_transport(transport)
+def set_default_openai_agent_registration(
+ config: OpenAIAgentRegistrationConfig | None,
+) -> None:
+ """Set the default OpenAI agent registration config.
+
+ This controls the agent harness ID that OpenAI providers resolve from SDK configuration. If
+ this is not set, providers fall back to the ``OPENAI_AGENT_HARNESS_ID`` environment variable.
+ """
+ _config.set_default_openai_agent_registration(config)
+
+
+def set_default_openai_harness(harness_id: str | None) -> None:
+ """Set the default OpenAI agent harness ID for SDK-managed OpenAI providers.
+
+ Passing ``None`` clears the default and restores environment variable fallback.
+ """
+ _config.set_default_openai_harness(harness_id)
+
+
def enable_verbose_stdout_logging():
"""Enables verbose logging to stdout. This is useful for debugging."""
logger = logging.getLogger("openai.agents")
@@ -320,6 +341,7 @@ __all__ = [
"OpenAIChatCompletionsModel",
"MultiProvider",
"OpenAIProvider",
+ "OpenAIAgentRegistrationConfig",
"OpenAIResponsesModel",
"OpenAIResponsesWSModel",
"AgentOutputSchema",
@@ -411,6 +433,7 @@ __all__ = [
"FunctionToolResult",
"ComputerTool",
"ComputerProvider",
+ "CustomTool",
"FileSearchTool",
"CodeInterpreterTool",
"ImageGenerationTool",
@@ -498,11 +521,14 @@ __all__ = [
"set_default_openai_client",
"set_default_openai_api",
"set_default_openai_responses_transport",
+ "set_default_openai_harness",
+ "set_default_openai_agent_registration",
"responses_websocket_session",
"set_tracing_export_api_key",
"enable_verbose_stdout_logging",
"gen_trace_id",
"gen_span_id",
"default_tool_error_function",
+ "sandbox",
"__version__",
]
diff --git a/src/agents/_config.py b/src/agents/_config.py
index d8ff2873..e5bdd3d0 100644
--- a/src/agents/_config.py
+++ b/src/agents/_config.py
@@ -1,7 +1,12 @@
+from typing import Literal
+
from openai import AsyncOpenAI
-from typing_extensions import Literal
from .models import _openai_shared
+from .models.openai_agent_registration import (
+ OpenAIAgentRegistrationConfig,
+ set_default_openai_agent_registration_config,
+)
from .tracing import set_tracing_export_api_key
@@ -32,3 +37,19 @@ def set_default_openai_responses_transport(transport: Literal["http", "websocket
"Invalid OpenAI Responses transport. Expected one of: 'http', 'websocket'."
)
_openai_shared.set_default_openai_responses_transport(transport)
+
+
+def set_default_openai_agent_registration(
+ config: OpenAIAgentRegistrationConfig | None,
+) -> None:
+ set_default_openai_agent_registration_config(config)
+
+
+def set_default_openai_harness(harness_id: str | None) -> None:
+ if harness_id is None:
+ set_default_openai_agent_registration_config(None)
+ return
+
+ set_default_openai_agent_registration_config(
+ OpenAIAgentRegistrationConfig(harness_id=harness_id)
+ )
diff --git a/src/agents/_public_agent.py b/src/agents/_public_agent.py
new file mode 100644
index 00000000..e9550a31
--- /dev/null
+++ b/src/agents/_public_agent.py
@@ -0,0 +1,21 @@
+"""Helpers for preserving the user-visible agent identity during execution rewrites."""
+
+from __future__ import annotations
+
+from .agent import Agent
+
+_PUBLIC_AGENT_ATTR = "_agents_public_agent"
+
+
+def set_public_agent(execution_agent: Agent, public_agent: Agent) -> Agent:
+ """Tag an execution-only clone with the agent identity exposed to hooks and results."""
+ setattr(execution_agent, _PUBLIC_AGENT_ATTR, public_agent)
+ return execution_agent
+
+
+def get_public_agent(agent: Agent) -> Agent:
+ """Return the user-visible agent identity for hooks, tool execution, and results."""
+ public_agent = getattr(agent, _PUBLIC_AGENT_ATTR, None)
+ if isinstance(public_agent, Agent):
+ return public_agent
+ return agent
diff --git a/src/agents/agent.py b/src/agents/agent.py
index 5d700eba..4c70b216 100644
--- a/src/agents/agent.py
+++ b/src/agents/agent.py
@@ -3,13 +3,13 @@ from __future__ import annotations
import asyncio
import dataclasses
import inspect
-from collections.abc import Awaitable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, cast
+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, cast
from openai.types.responses.response_prompt_param import ResponsePromptParam
from pydantic import BaseModel, TypeAdapter, ValidationError
-from typing_extensions import NotRequired, TypeAlias, TypedDict
+from typing_extensions import NotRequired, TypedDict
from ._tool_identity import get_function_tool_approval_keys
from .agent_output import AgentOutputSchemaBase
@@ -211,7 +211,7 @@ class AgentBase(Generic[TContext]):
return bool(res)
results = await asyncio.gather(*(_check_tool_enabled(t) for t in self.tools))
- enabled: list[Tool] = [t for t, ok in zip(self.tools, results) if ok]
+ enabled: list[Tool] = [t for t, ok in zip(self.tools, results, strict=False) if ok]
all_tools: list[Tool] = prune_orphaned_tool_search_tools([*mcp_tools, *enabled])
_validate_codex_tool_name_collisions(all_tools)
return all_tools
@@ -416,7 +416,7 @@ class Agent(AgentBase, Generic[TContext]):
from .agent_output import AgentOutputSchemaBase
if not (
- isinstance(self.output_type, (type, AgentOutputSchemaBase))
+ isinstance(self.output_type, type | AgentOutputSchemaBase)
or get_origin(self.output_type) is not None
):
raise TypeError(
@@ -925,4 +925,10 @@ class Agent(AgentBase, Generic[TContext]):
self, run_context: RunContextWrapper[TContext]
) -> ResponsePromptParam | None:
"""Get the prompt for the agent."""
- return await PromptUtil.to_model_input(self.prompt, run_context, self)
+ from ._public_agent import get_public_agent
+
+ return await PromptUtil.to_model_input(
+ self.prompt,
+ run_context,
+ cast(Agent[TContext], get_public_agent(self)),
+ )
diff --git a/src/agents/agent_output.py b/src/agents/agent_output.py
index 61d4a1c2..5e4974e8 100644
--- a/src/agents/agent_output.py
+++ b/src/agents/agent_output.py
@@ -1,9 +1,9 @@
import abc
from dataclasses import dataclass
-from typing import Any
+from typing import Any, get_args, get_origin
from pydantic import BaseModel, TypeAdapter
-from typing_extensions import TypedDict, get_args, get_origin
+from typing_extensions import TypedDict
from .exceptions import ModelBehaviorError, UserError
from .strict_schema import ensure_strict_json_schema
diff --git a/src/agents/agent_tool_input.py b/src/agents/agent_tool_input.py
index 0f1e5df6..19a81e62 100644
--- a/src/agents/agent_tool_input.py
+++ b/src/agents/agent_tool_input.py
@@ -2,9 +2,9 @@ from __future__ import annotations
import inspect
import json
-from collections.abc import Awaitable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
-from typing import Any, Callable, TypedDict, Union, cast
+from typing import Any, TypedDict, cast
from pydantic import BaseModel
@@ -40,10 +40,10 @@ class StructuredToolInputBuilderOptions(TypedDict, total=False):
json_schema: dict[str, Any] | None
-StructuredToolInputResult = Union[str, list[TResponseInputItem]]
+StructuredToolInputResult = str | list[TResponseInputItem]
StructuredToolInputBuilder = Callable[
[StructuredToolInputBuilderOptions],
- Union[StructuredToolInputResult, Awaitable[StructuredToolInputResult]],
+ StructuredToolInputResult | Awaitable[StructuredToolInputResult],
]
diff --git a/src/agents/apply_diff.py b/src/agents/apply_diff.py
index 82bc2b42..4d35f6d7 100644
--- a/src/agents/apply_diff.py
+++ b/src/agents/apply_diff.py
@@ -3,9 +3,9 @@
from __future__ import annotations
import re
-from collections.abc import Sequence
+from collections.abc import Callable, Sequence
from dataclasses import dataclass
-from typing import Callable, Literal
+from typing import Literal
ApplyDiffMode = Literal["default", "create"]
diff --git a/src/agents/editor.py b/src/agents/editor.py
index 40a1374b..a6198bfd 100644
--- a/src/agents/editor.py
+++ b/src/agents/editor.py
@@ -20,6 +20,7 @@ class ApplyPatchOperation:
path: str
diff: str | None = None
ctx_wrapper: RunContextWrapper | None = None
+ move_to: str | None = None
@dataclass(**_DATACLASS_KWARGS)
diff --git a/src/agents/extensions/experimental/codex/codex_tool.py b/src/agents/extensions/experimental/codex/codex_tool.py
index fefe91bc..854aa65f 100644
--- a/src/agents/extensions/experimental/codex/codex_tool.py
+++ b/src/agents/extensions/experimental/codex/codex_tool.py
@@ -6,13 +6,13 @@ import inspect
import json
import os
import re
-from collections.abc import AsyncGenerator, Awaitable, Mapping, MutableMapping
+from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, MutableMapping
from dataclasses import dataclass
-from typing import Any, Callable, Union
+from typing import Any, Literal, TypeAlias, TypeGuard
from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
-from typing_extensions import Literal, NotRequired, TypeAlias, TypedDict, TypeGuard
+from typing_extensions import NotRequired, TypedDict
from agents import _debug
from agents.exceptions import ModelBehaviorError, UserError
@@ -48,8 +48,6 @@ from .events import (
)
from .items import (
CommandExecutionItem,
- McpToolCallItem,
- ReasoningItem,
ThreadItem,
is_agent_message_item,
)
@@ -159,7 +157,7 @@ class OutputSchemaArray(TypedDict, total=False):
items: OutputSchemaPrimitive
-OutputSchemaField: TypeAlias = Union[OutputSchemaPrimitive, OutputSchemaArray]
+OutputSchemaField: TypeAlias = OutputSchemaPrimitive | OutputSchemaArray
class OutputSchemaPropertyDescriptor(TypedDict, total=False):
@@ -1025,7 +1023,7 @@ async def _consume_events(
span_data_max_chars: int | None,
resolved_thread_id_holder: dict[str, str | None] | None = None,
) -> tuple[str, Usage | None, str | None]:
- # Track spans keyed by item id for command/mcp/reasoning events.
+ # Track spans keyed by item id for command execution events.
active_spans: dict[str, Any] = {}
final_response = ""
usage: Usage | None = None
@@ -1144,40 +1142,6 @@ def _handle_item_started(
spans[item_id] = span
return
- if _is_mcp_tool_call_item(item):
- data = _merge_span_data(
- {},
- {
- "server": item.server,
- "tool": item.tool,
- "status": item.status,
- "arguments": _truncate_span_value(
- _maybe_as_dict(item.arguments), span_data_max_chars
- ),
- },
- span_data_max_chars,
- )
- span = custom_span(
- name="Codex MCP tool call",
- data=data,
- )
- span.start()
- spans[item_id] = span
- return
-
- if _is_reasoning_item(item):
- data = _merge_span_data(
- {},
- {"text": _truncate_span_value(item.text, span_data_max_chars)},
- span_data_max_chars,
- )
- span = custom_span(
- name="Codex reasoning",
- data=data,
- )
- span.start()
- spans[item_id] = span
-
def _handle_item_updated(
item: ThreadItem, spans: dict[str, Any], span_data_max_chars: int | None
@@ -1191,10 +1155,6 @@ def _handle_item_updated(
if _is_command_execution_item(item):
_update_command_span(span, item, span_data_max_chars)
- elif _is_mcp_tool_call_item(item):
- _update_mcp_tool_span(span, item, span_data_max_chars)
- elif _is_reasoning_item(item):
- _update_reasoning_span(span, item, span_data_max_chars)
def _handle_item_completed(
@@ -1222,13 +1182,6 @@ def _handle_item_completed(
data=error_data,
)
)
- elif _is_mcp_tool_call_item(item):
- _update_mcp_tool_span(span, item, span_data_max_chars)
- error = item.error
- if item.status == "failed" and error is not None and error.message:
- span.set_error(SpanError(message=error.message, data={}))
- elif _is_reasoning_item(item):
- _update_reasoning_span(span, item, span_data_max_chars)
span.finish()
spans.pop(item_id, None)
@@ -1271,20 +1224,10 @@ def _stringify_span_value(value: Any) -> str:
return str(value)
-def _maybe_as_dict(value: Any) -> Any:
- if isinstance(value, _DictLike):
- return value.as_dict()
- if isinstance(value, list):
- return [_maybe_as_dict(item) for item in value]
- if isinstance(value, dict):
- return {key: _maybe_as_dict(item) for key, item in value.items()}
- return value
-
-
def _truncate_span_value(value: Any, max_chars: int | None) -> Any:
if max_chars is None:
return value
- if value is None or isinstance(value, (bool, int, float)):
+ if value is None or isinstance(value, bool | int | float):
return value
if isinstance(value, str):
return _truncate_span_string(value, max_chars)
@@ -1458,31 +1401,6 @@ def _update_command_span(
)
-def _update_mcp_tool_span(
- span: Any, item: McpToolCallItem, span_data_max_chars: int | None
-) -> None:
- _apply_span_updates(
- span,
- {
- "server": item.server,
- "tool": item.tool,
- "status": item.status,
- "arguments": _truncate_span_value(_maybe_as_dict(item.arguments), span_data_max_chars),
- "result": _truncate_span_value(_maybe_as_dict(item.result), span_data_max_chars),
- "error": _truncate_span_value(_maybe_as_dict(item.error), span_data_max_chars),
- },
- span_data_max_chars,
- )
-
-
-def _update_reasoning_span(span: Any, item: ReasoningItem, span_data_max_chars: int | None) -> None:
- _apply_span_updates(
- span,
- {"text": _truncate_span_value(item.text, span_data_max_chars)},
- span_data_max_chars,
- )
-
-
def _build_default_response(args: CodexToolCallArguments) -> str:
input_summary = "with inputs." if args.get("inputs") else "with no inputs."
return f"Codex task completed {input_summary}"
@@ -1490,11 +1408,3 @@ def _build_default_response(args: CodexToolCallArguments) -> str:
def _is_command_execution_item(item: ThreadItem) -> TypeGuard[CommandExecutionItem]:
return isinstance(item, CommandExecutionItem)
-
-
-def _is_mcp_tool_call_item(item: ThreadItem) -> TypeGuard[McpToolCallItem]:
- return isinstance(item, McpToolCallItem)
-
-
-def _is_reasoning_item(item: ThreadItem) -> TypeGuard[ReasoningItem]:
- return isinstance(item, ReasoningItem)
diff --git a/src/agents/extensions/experimental/codex/events.py b/src/agents/extensions/experimental/codex/events.py
index 9514a81a..b4caab46 100644
--- a/src/agents/extensions/experimental/codex/events.py
+++ b/src/agents/extensions/experimental/codex/events.py
@@ -2,9 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
-from typing import Any, Union, cast
-
-from typing_extensions import Literal, TypeAlias
+from typing import Any, Literal, TypeAlias, cast
from .items import ThreadItem, coerce_thread_item
from .payloads import _DictLike
@@ -77,17 +75,17 @@ class _UnknownThreadEvent(_DictLike):
payload: Mapping[str, Any] = field(default_factory=dict)
-ThreadEvent: TypeAlias = Union[
- ThreadStartedEvent,
- TurnStartedEvent,
- TurnCompletedEvent,
- TurnFailedEvent,
- ItemStartedEvent,
- ItemUpdatedEvent,
- ItemCompletedEvent,
- ThreadErrorEvent,
- _UnknownThreadEvent,
-]
+ThreadEvent: TypeAlias = (
+ ThreadStartedEvent
+ | TurnStartedEvent
+ | TurnCompletedEvent
+ | TurnFailedEvent
+ | ItemStartedEvent
+ | ItemUpdatedEvent
+ | ItemCompletedEvent
+ | ThreadErrorEvent
+ | _UnknownThreadEvent
+)
def _coerce_thread_error(raw: ThreadError | Mapping[str, Any]) -> ThreadError:
@@ -132,7 +130,7 @@ def coerce_thread_event(raw: ThreadEvent | Mapping[str, Any]) -> ThreadEvent:
if event_type == "item.started":
item_raw = raw.get("item")
item = (
- coerce_thread_item(cast(Union[ThreadItem, Mapping[str, Any]], item_raw))
+ coerce_thread_item(cast(ThreadItem | Mapping[str, Any], item_raw))
if item_raw is not None
else coerce_thread_item({"type": "unknown"})
)
@@ -140,7 +138,7 @@ def coerce_thread_event(raw: ThreadEvent | Mapping[str, Any]) -> ThreadEvent:
if event_type == "item.updated":
item_raw = raw.get("item")
item = (
- coerce_thread_item(cast(Union[ThreadItem, Mapping[str, Any]], item_raw))
+ coerce_thread_item(cast(ThreadItem | Mapping[str, Any], item_raw))
if item_raw is not None
else coerce_thread_item({"type": "unknown"})
)
@@ -148,7 +146,7 @@ def coerce_thread_event(raw: ThreadEvent | Mapping[str, Any]) -> ThreadEvent:
if event_type == "item.completed":
item_raw = raw.get("item")
item = (
- coerce_thread_item(cast(Union[ThreadItem, Mapping[str, Any]], item_raw))
+ coerce_thread_item(cast(ThreadItem | Mapping[str, Any], item_raw))
if item_raw is not None
else coerce_thread_item({"type": "unknown"})
)
diff --git a/src/agents/extensions/experimental/codex/items.py b/src/agents/extensions/experimental/codex/items.py
index 63d80f0d..5c4029c6 100644
--- a/src/agents/extensions/experimental/codex/items.py
+++ b/src/agents/extensions/experimental/codex/items.py
@@ -2,9 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Any, Optional, Union, cast
-
-from typing_extensions import Literal, TypeAlias, TypeGuard
+from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeGuard, cast
from .payloads import _DictLike
@@ -116,17 +114,17 @@ class _UnknownThreadItem(_DictLike):
id: str | None = None
-ThreadItem: TypeAlias = Union[
- AgentMessageItem,
- ReasoningItem,
- CommandExecutionItem,
- FileChangeItem,
- McpToolCallItem,
- WebSearchItem,
- TodoListItem,
- ErrorItem,
- _UnknownThreadItem,
-]
+ThreadItem: TypeAlias = (
+ AgentMessageItem
+ | ReasoningItem
+ | CommandExecutionItem
+ | FileChangeItem
+ | McpToolCallItem
+ | WebSearchItem
+ | TodoListItem
+ | ErrorItem
+ | _UnknownThreadItem
+)
def is_agent_message_item(item: ThreadItem) -> TypeGuard[AgentMessageItem]:
@@ -183,7 +181,7 @@ def coerce_thread_item(raw: ThreadItem | Mapping[str, Any]) -> ThreadItem:
command=cast(str, raw["command"]),
aggregated_output=cast(str, raw.get("aggregated_output", "")),
status=cast(CommandExecutionStatus, raw["status"]),
- exit_code=cast(Optional[int], raw.get("exit_code")),
+ exit_code=cast(int | None, raw.get("exit_code")),
)
if item_type == "file_change":
changes = [_coerce_file_update_change(change) for change in raw.get("changes", [])]
@@ -241,5 +239,5 @@ def coerce_thread_item(raw: ThreadItem | Mapping[str, Any]) -> ThreadItem:
return _UnknownThreadItem(
type=cast(str, item_type) if item_type is not None else "unknown",
payload=dict(raw),
- id=cast(Optional[str], raw.get("id")),
+ id=cast(str | None, raw.get("id")),
)
diff --git a/src/agents/extensions/experimental/codex/output_schema_file.py b/src/agents/extensions/experimental/codex/output_schema_file.py
index a794bd9c..b53a3780 100644
--- a/src/agents/extensions/experimental/codex/output_schema_file.py
+++ b/src/agents/extensions/experimental/codex/output_schema_file.py
@@ -4,8 +4,9 @@ import json
import os
import shutil
import tempfile
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any, Callable
+from typing import Any
from agents.exceptions import UserError
diff --git a/src/agents/extensions/experimental/codex/thread.py b/src/agents/extensions/experimental/codex/thread.py
index 522f6e95..2ba687dc 100644
--- a/src/agents/extensions/experimental/codex/thread.py
+++ b/src/agents/extensions/experimental/codex/thread.py
@@ -4,9 +4,9 @@ import asyncio
import contextlib
from collections.abc import AsyncGenerator
from dataclasses import dataclass
-from typing import Any, Union, cast
+from typing import Any, Literal, TypeAlias, cast
-from typing_extensions import Literal, TypeAlias, TypedDict
+from typing_extensions import TypedDict
from .codex_options import CodexOptions
from .events import (
@@ -47,8 +47,8 @@ class LocalImageInput(TypedDict):
path: str
-UserInput: TypeAlias = Union[TextInput, LocalImageInput]
-Input: TypeAlias = Union[str, list[UserInput]]
+UserInput: TypeAlias = TextInput | LocalImageInput
+Input: TypeAlias = str | list[UserInput]
@dataclass(frozen=True)
diff --git a/src/agents/extensions/experimental/codex/thread_options.py b/src/agents/extensions/experimental/codex/thread_options.py
index 75e7882c..31746c20 100644
--- a/src/agents/extensions/experimental/codex/thread_options.py
+++ b/src/agents/extensions/experimental/codex/thread_options.py
@@ -2,9 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, fields
-from typing import Any
-
-from typing_extensions import Literal
+from typing import Any, Literal
from agents.exceptions import UserError
diff --git a/src/agents/extensions/memory/advanced_sqlite_session.py b/src/agents/extensions/memory/advanced_sqlite_session.py
index f0c3cb8f..5b384eaf 100644
--- a/src/agents/extensions/memory/advanced_sqlite_session.py
+++ b/src/agents/extensions/memory/advanced_sqlite_session.py
@@ -6,7 +6,7 @@ import logging
import sqlite3
from contextlib import closing
from pathlib import Path
-from typing import Any, Union, cast
+from typing import Any, cast
from agents.result import RunResult
from agents.usage import Usage
@@ -430,7 +430,7 @@ class AdvancedSQLiteSession(SQLiteSession):
structure_data = []
user_message_count = 0
- for i, (item, msg_id) in enumerate(zip(items, message_ids)):
+ for i, (item, msg_id) in enumerate(zip(items, message_ids, strict=False)):
msg_type = self._classify_message_type(item)
tool_name = self._extract_tool_name(item)
@@ -1193,7 +1193,7 @@ class AdvancedSQLiteSession(SQLiteSession):
result = await asyncio.to_thread(_get_usage_sync)
- return cast(Union[dict[str, int], None], result)
+ return cast(dict[str, int] | None, result)
async def get_turn_usage(
self,
@@ -1298,7 +1298,7 @@ class AdvancedSQLiteSession(SQLiteSession):
result = await asyncio.to_thread(_get_turn_usage_sync)
- return cast(Union[list[dict[str, Any]], dict[str, Any]], result)
+ return cast(list[dict[str, Any]] | dict[str, Any], result)
async def _update_turn_usage_internal(self, user_turn_number: int, usage_data: Usage) -> None:
"""Internal method to update usage for a specific turn with full JSON details.
diff --git a/src/agents/extensions/memory/encrypt_session.py b/src/agents/extensions/memory/encrypt_session.py
index d7f2e8ed..a72aee0a 100644
--- a/src/agents/extensions/memory/encrypt_session.py
+++ b/src/agents/extensions/memory/encrypt_session.py
@@ -29,12 +29,12 @@ from __future__ import annotations
import base64
import json
-from typing import Any, cast
+from typing import Any, Literal, TypeGuard, cast
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-from typing_extensions import Literal, TypedDict, TypeGuard
+from typing_extensions import TypedDict
from ...items import TResponseInputItem
from ...memory.session import SessionABC
diff --git a/src/agents/extensions/models/any_llm_model.py b/src/agents/extensions/models/any_llm_model.py
index 7f321a91..dc89be49 100644
--- a/src/agents/extensions/models/any_llm_model.py
+++ b/src/agents/extensions/models/any_llm_model.py
@@ -169,7 +169,7 @@ def _flatten_any_llm_reasoning_value(value: Any) -> str:
if flattened:
return flattened
- if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
+ if isinstance(value, Iterable) and not isinstance(value, str | bytes):
parts = [_flatten_any_llm_reasoning_value(item) for item in value]
return "".join(part for part in parts if part)
return ""
diff --git a/src/agents/extensions/sandbox/__init__.py b/src/agents/extensions/sandbox/__init__.py
new file mode 100644
index 00000000..d7b082ba
--- /dev/null
+++ b/src/agents/extensions/sandbox/__init__.py
@@ -0,0 +1,209 @@
+try:
+ from .e2b import (
+ E2BCloudBucketMountStrategy as E2BCloudBucketMountStrategy,
+ E2BSandboxClient as E2BSandboxClient,
+ E2BSandboxClientOptions as E2BSandboxClientOptions,
+ E2BSandboxSession as E2BSandboxSession,
+ E2BSandboxSessionState as E2BSandboxSessionState,
+ E2BSandboxTimeouts as E2BSandboxTimeouts,
+ E2BSandboxType as E2BSandboxType,
+ )
+
+ _HAS_E2B = True
+except Exception: # pragma: no cover
+ _HAS_E2B = False
+
+try:
+ from .modal import (
+ ModalCloudBucketMountStrategy as ModalCloudBucketMountStrategy,
+ ModalSandboxClient as ModalSandboxClient,
+ ModalSandboxClientOptions as ModalSandboxClientOptions,
+ ModalSandboxSession as ModalSandboxSession,
+ ModalSandboxSessionState as ModalSandboxSessionState,
+ )
+
+ _HAS_MODAL = True
+except Exception: # pragma: no cover
+ _HAS_MODAL = False
+
+try:
+ from .daytona import (
+ DEFAULT_DAYTONA_WORKSPACE_ROOT as DEFAULT_DAYTONA_WORKSPACE_ROOT,
+ DaytonaCloudBucketMountStrategy as DaytonaCloudBucketMountStrategy,
+ DaytonaSandboxClient as DaytonaSandboxClient,
+ DaytonaSandboxClientOptions as DaytonaSandboxClientOptions,
+ DaytonaSandboxResources as DaytonaSandboxResources,
+ DaytonaSandboxSession as DaytonaSandboxSession,
+ DaytonaSandboxSessionState as DaytonaSandboxSessionState,
+ DaytonaSandboxTimeouts as DaytonaSandboxTimeouts,
+ )
+
+ _HAS_DAYTONA = True
+except Exception: # pragma: no cover
+ _HAS_DAYTONA = False
+
+try:
+ from .blaxel import (
+ DEFAULT_BLAXEL_WORKSPACE_ROOT as DEFAULT_BLAXEL_WORKSPACE_ROOT,
+ BlaxelCloudBucketMountConfig as BlaxelCloudBucketMountConfig,
+ BlaxelCloudBucketMountStrategy as BlaxelCloudBucketMountStrategy,
+ BlaxelDriveMountConfig as BlaxelDriveMountConfig,
+ BlaxelDriveMountStrategy as BlaxelDriveMountStrategy,
+ BlaxelSandboxClient as BlaxelSandboxClient,
+ BlaxelSandboxClientOptions as BlaxelSandboxClientOptions,
+ BlaxelSandboxSession as BlaxelSandboxSession,
+ BlaxelSandboxSessionState as BlaxelSandboxSessionState,
+ BlaxelTimeouts as BlaxelTimeouts,
+ )
+
+ _HAS_BLAXEL = True
+except Exception: # pragma: no cover
+ _HAS_BLAXEL = False
+
+try:
+ from .cloudflare import (
+ CloudflareBucketMountConfig as CloudflareBucketMountConfig,
+ CloudflareBucketMountStrategy as CloudflareBucketMountStrategy,
+ CloudflareSandboxClient as CloudflareSandboxClient,
+ CloudflareSandboxClientOptions as CloudflareSandboxClientOptions,
+ CloudflareSandboxSession as CloudflareSandboxSession,
+ CloudflareSandboxSessionState as CloudflareSandboxSessionState,
+ )
+
+ _HAS_CLOUDFLARE = True
+except Exception: # pragma: no cover
+ _HAS_CLOUDFLARE = False
+
+try:
+ from .runloop import (
+ DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT as DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT,
+ DEFAULT_RUNLOOP_WORKSPACE_ROOT as DEFAULT_RUNLOOP_WORKSPACE_ROOT,
+ RunloopAfterIdle as RunloopAfterIdle,
+ RunloopCloudBucketMountStrategy as RunloopCloudBucketMountStrategy,
+ RunloopGatewaySpec as RunloopGatewaySpec,
+ RunloopLaunchParameters as RunloopLaunchParameters,
+ RunloopMcpSpec as RunloopMcpSpec,
+ RunloopPlatformClient as RunloopPlatformClient,
+ RunloopSandboxClient as RunloopSandboxClient,
+ RunloopSandboxClientOptions as RunloopSandboxClientOptions,
+ RunloopSandboxSession as RunloopSandboxSession,
+ RunloopSandboxSessionState as RunloopSandboxSessionState,
+ RunloopTimeouts as RunloopTimeouts,
+ RunloopTunnelConfig as RunloopTunnelConfig,
+ RunloopUserParameters as RunloopUserParameters,
+ )
+
+ _HAS_RUNLOOP = True
+except Exception: # pragma: no cover
+ _HAS_RUNLOOP = False
+
+try:
+ from .vercel import (
+ VercelSandboxClient as VercelSandboxClient,
+ VercelSandboxClientOptions as VercelSandboxClientOptions,
+ VercelSandboxSession as VercelSandboxSession,
+ VercelSandboxSessionState as VercelSandboxSessionState,
+ )
+
+ _HAS_VERCEL = True
+except Exception: # pragma: no cover
+ _HAS_VERCEL = False
+
+__all__: list[str] = []
+
+if _HAS_E2B:
+ __all__.extend(
+ [
+ "E2BCloudBucketMountStrategy",
+ "E2BSandboxClient",
+ "E2BSandboxClientOptions",
+ "E2BSandboxSession",
+ "E2BSandboxSessionState",
+ "E2BSandboxTimeouts",
+ "E2BSandboxType",
+ ]
+ )
+
+if _HAS_MODAL:
+ __all__.extend(
+ [
+ "ModalCloudBucketMountStrategy",
+ "ModalSandboxClient",
+ "ModalSandboxClientOptions",
+ "ModalSandboxSession",
+ "ModalSandboxSessionState",
+ ]
+ )
+
+if _HAS_DAYTONA:
+ __all__.extend(
+ [
+ "DEFAULT_DAYTONA_WORKSPACE_ROOT",
+ "DaytonaCloudBucketMountStrategy",
+ "DaytonaSandboxResources",
+ "DaytonaSandboxClient",
+ "DaytonaSandboxClientOptions",
+ "DaytonaSandboxSession",
+ "DaytonaSandboxSessionState",
+ "DaytonaSandboxTimeouts",
+ ]
+ )
+
+if _HAS_BLAXEL:
+ __all__.extend(
+ [
+ "DEFAULT_BLAXEL_WORKSPACE_ROOT",
+ "BlaxelCloudBucketMountConfig",
+ "BlaxelCloudBucketMountStrategy",
+ "BlaxelDriveMountConfig",
+ "BlaxelDriveMountStrategy",
+ "BlaxelSandboxClient",
+ "BlaxelSandboxClientOptions",
+ "BlaxelSandboxSession",
+ "BlaxelSandboxSessionState",
+ "BlaxelTimeouts",
+ ]
+ )
+
+if _HAS_CLOUDFLARE:
+ __all__.extend(
+ [
+ "CloudflareBucketMountConfig",
+ "CloudflareBucketMountStrategy",
+ "CloudflareSandboxClient",
+ "CloudflareSandboxClientOptions",
+ "CloudflareSandboxSession",
+ "CloudflareSandboxSessionState",
+ ]
+ )
+
+if _HAS_VERCEL:
+ __all__.extend(
+ [
+ "VercelSandboxClient",
+ "VercelSandboxClientOptions",
+ "VercelSandboxSession",
+ "VercelSandboxSessionState",
+ ]
+ )
+
+if _HAS_RUNLOOP:
+ __all__.extend(
+ [
+ "DEFAULT_RUNLOOP_WORKSPACE_ROOT",
+ "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT",
+ "RunloopAfterIdle",
+ "RunloopGatewaySpec",
+ "RunloopLaunchParameters",
+ "RunloopMcpSpec",
+ "RunloopPlatformClient",
+ "RunloopCloudBucketMountStrategy",
+ "RunloopSandboxClient",
+ "RunloopSandboxClientOptions",
+ "RunloopSandboxSession",
+ "RunloopSandboxSessionState",
+ "RunloopTimeouts",
+ "RunloopTunnelConfig",
+ "RunloopUserParameters",
+ ]
+ )
diff --git a/src/agents/extensions/sandbox/blaxel/__init__.py b/src/agents/extensions/sandbox/blaxel/__init__.py
new file mode 100644
index 00000000..b173dd2e
--- /dev/null
+++ b/src/agents/extensions/sandbox/blaxel/__init__.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from ....sandbox.errors import (
+ ExposedPortUnavailableError,
+ InvalidManifestPathError,
+ WorkspaceArchiveReadError,
+)
+from .mounts import (
+ BlaxelCloudBucketMountConfig,
+ BlaxelCloudBucketMountStrategy,
+ BlaxelDriveMount,
+ BlaxelDriveMountConfig,
+ BlaxelDriveMountStrategy,
+)
+from .sandbox import (
+ DEFAULT_BLAXEL_WORKSPACE_ROOT,
+ BlaxelSandboxClient,
+ BlaxelSandboxClientOptions,
+ BlaxelSandboxSession,
+ BlaxelSandboxSessionState,
+ BlaxelTimeouts,
+)
+
+__all__ = [
+ "DEFAULT_BLAXEL_WORKSPACE_ROOT",
+ "BlaxelCloudBucketMountConfig",
+ "BlaxelCloudBucketMountStrategy",
+ "BlaxelDriveMount",
+ "BlaxelDriveMountConfig",
+ "BlaxelDriveMountStrategy",
+ "BlaxelSandboxClient",
+ "BlaxelSandboxClientOptions",
+ "BlaxelSandboxSession",
+ "BlaxelSandboxSessionState",
+ "BlaxelTimeouts",
+ "ExposedPortUnavailableError",
+ "InvalidManifestPathError",
+ "WorkspaceArchiveReadError",
+]
diff --git a/src/agents/extensions/sandbox/blaxel/mounts.py b/src/agents/extensions/sandbox/blaxel/mounts.py
new file mode 100644
index 00000000..9b87802e
--- /dev/null
+++ b/src/agents/extensions/sandbox/blaxel/mounts.py
@@ -0,0 +1,676 @@
+"""
+Mount strategies for Blaxel sandboxes.
+
+Two strategies are provided:
+
+* **BlaxelCloudBucketMountStrategy** -- mounts S3, R2, and GCS buckets via
+ FUSE tools (``s3fs``, ``gcsfuse``) executed inside the sandbox. Credentials
+ are written to ephemeral temp files, referenced by the FUSE tool, and deleted
+ immediately after the mount succeeds.
+
+* **BlaxelDriveMountStrategy** -- mounts Blaxel Drives (persistent network
+ volumes) into the sandbox using the sandbox ``drives`` API
+ (``POST /drives/mount``). Drives persist data across sandbox sessions and
+ can be shared between sandboxes. See
+ `Blaxel Drive docs `_.
+"""
+
+from __future__ import annotations
+
+import logging
+import shlex
+import uuid
+import warnings
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Literal
+
+from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount
+from ....sandbox.entries.mounts.base import MountStrategyBase
+from ....sandbox.errors import MountConfigError
+from ....sandbox.materialization import MaterializedFile
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+from ....sandbox.types import FileMode, Permissions
+
+logger = logging.getLogger(__name__)
+
+BlaxelBucketProvider = Literal["s3", "r2", "gcs"]
+
+
+@dataclass(frozen=True)
+class BlaxelCloudBucketMountConfig:
+ """Resolved mount config ready to be executed inside a Blaxel sandbox."""
+
+ provider: BlaxelBucketProvider
+ bucket: str
+ mount_path: str
+ read_only: bool = True
+
+ # S3 / R2 fields.
+ access_key_id: str | None = None
+ secret_access_key: str | None = None
+ session_token: str | None = None
+ region: str | None = None
+ endpoint_url: str | None = None
+ prefix: str | None = None
+
+ # GCS fields.
+ service_account_key: str | None = None
+
+
+class BlaxelCloudBucketMountStrategy(MountStrategyBase):
+ """Mount S3/R2/GCS buckets inside Blaxel sandboxes via FUSE tools.
+
+ ``activate`` installs the FUSE tool (if needed) and runs the mount command
+ inside the sandbox. ``deactivate`` / ``teardown_for_snapshot`` unmount via
+ ``fusermount`` or ``umount``.
+ """
+
+ type: Literal["blaxel_cloud_bucket"] = "blaxel_cloud_bucket"
+
+ def validate_mount(self, mount: Mount) -> None:
+ _build_mount_config(mount, mount_path="/validate")
+
+ async def activate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> list[MaterializedFile]:
+ _assert_blaxel_session(session)
+ _ = base_dir
+ mount_path = mount._resolve_mount_path(session, dest)
+ config = _build_mount_config(mount, mount_path=str(mount_path))
+ await _mount_bucket(session, config)
+ return []
+
+ async def deactivate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> None:
+ _assert_blaxel_session(session)
+ _ = base_dir
+ mount_path = mount._resolve_mount_path(session, dest)
+ await _unmount_bucket(session, str(mount_path))
+
+ async def teardown_for_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_blaxel_session(session)
+ _ = mount
+ await _unmount_bucket(session, str(path))
+
+ async def restore_after_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_blaxel_session(session)
+ config = _build_mount_config(mount, mount_path=str(path))
+ await _mount_bucket(session, config)
+
+ def build_docker_volume_driver_config(
+ self,
+ mount: Mount,
+ ) -> tuple[str, dict[str, str], bool] | None:
+ _ = mount
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+_INSTALL_RETRIES = 3
+
+
+def _assert_blaxel_session(session: BaseSandboxSession) -> None:
+ if type(session).__name__ != "BlaxelSandboxSession":
+ raise MountConfigError(
+ message="blaxel cloud bucket mounts require a BlaxelSandboxSession",
+ context={"session_type": type(session).__name__},
+ )
+
+
+def _build_mount_config(mount: Mount, *, mount_path: str) -> BlaxelCloudBucketMountConfig:
+ """Translate an S3Mount / R2Mount / GCSMount into a BlaxelCloudBucketMountConfig."""
+
+ if isinstance(mount, S3Mount):
+ return BlaxelCloudBucketMountConfig(
+ provider="s3",
+ bucket=mount.bucket,
+ mount_path=mount_path,
+ read_only=mount.read_only,
+ access_key_id=mount.access_key_id,
+ secret_access_key=mount.secret_access_key,
+ session_token=mount.session_token,
+ region=mount.region,
+ endpoint_url=mount.endpoint_url,
+ prefix=mount.prefix,
+ )
+
+ if isinstance(mount, R2Mount):
+ mount._validate_credential_pair()
+ return BlaxelCloudBucketMountConfig(
+ provider="r2",
+ bucket=mount.bucket,
+ mount_path=mount_path,
+ read_only=mount.read_only,
+ access_key_id=mount.access_key_id,
+ secret_access_key=mount.secret_access_key,
+ endpoint_url=(
+ mount.custom_domain or f"https://{mount.account_id}.r2.cloudflarestorage.com"
+ ),
+ )
+
+ if isinstance(mount, GCSMount):
+ if mount._use_s3_compatible_rclone():
+ return BlaxelCloudBucketMountConfig(
+ provider="s3",
+ bucket=mount.bucket,
+ mount_path=mount_path,
+ read_only=mount.read_only,
+ access_key_id=mount.access_id,
+ secret_access_key=mount.secret_access_key,
+ region=mount.region,
+ endpoint_url=mount.endpoint_url or "https://storage.googleapis.com",
+ prefix=mount.prefix,
+ )
+ return BlaxelCloudBucketMountConfig(
+ provider="gcs",
+ bucket=mount.bucket,
+ mount_path=mount_path,
+ read_only=mount.read_only,
+ service_account_key=mount.service_account_credentials,
+ prefix=mount.prefix,
+ )
+
+ raise MountConfigError(
+ message="blaxel cloud bucket mounts only support S3Mount, R2Mount, and GCSMount",
+ context={"mount_type": mount.type},
+ )
+
+
+async def _exec(session: BaseSandboxSession, cmd: str, timeout: float = 120) -> Any:
+ """Execute a shell command inside the sandbox and return the result."""
+ result = await session.exec("sh", "-c", cmd, timeout=timeout)
+ return result
+
+
+_APK_PACKAGE_NAMES: dict[str, str] = {
+ "s3fs": "s3fs-fuse",
+}
+
+# gcsfuse is not available in Alpine repos. We extract the static binary from the
+# official .deb package (ar archive containing a data tarball).
+_GCSFUSE_INSTALL_ALPINE = (
+ "apk add --no-cache fuse curl binutils && "
+ "GCSFUSE_VER=$("
+ "curl -s https://api.github.com/repos/GoogleCloudPlatform/gcsfuse/releases/latest "
+ '| grep -o \'"tag_name": *"[^"]*"\' | head -1 | grep -o \'v[0-9.]*\') && '
+ "curl -fsSL https://github.com/GoogleCloudPlatform/gcsfuse/releases/download/"
+ "${GCSFUSE_VER}/gcsfuse_${GCSFUSE_VER#v}_amd64.deb -o /tmp/gcsfuse.deb && "
+ "cd /tmp && ar x gcsfuse.deb && "
+ "tar -xf data.tar* -C / && "
+ "rm -f gcsfuse.deb control.tar* data.tar* debian-binary"
+)
+
+
+# gcsfuse on Debian requires adding the Google Cloud apt repository first.
+_GCSFUSE_INSTALL_DEBIAN = (
+ "DEBIAN_FRONTEND=noninteractive apt-get update -qq && "
+ "apt-get install -y -qq curl gpg lsb-release && "
+ "curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg "
+ "| gpg --dearmor -o /etc/apt/keyrings/gcsfuse.gpg && "
+ "CODENAME=$(lsb_release -cs) && "
+ 'echo "deb [signed-by=/etc/apt/keyrings/gcsfuse.gpg] '
+ 'https://packages.cloud.google.com/apt gcsfuse-${CODENAME} main" '
+ "| tee /etc/apt/sources.list.d/gcsfuse.list && "
+ "apt-get update -qq && "
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gcsfuse"
+)
+
+
+async def _install_tool(session: BaseSandboxSession, tool: str) -> None:
+ """Install a FUSE tool (s3fs or gcsfuse) via apk/apt-get with retries."""
+ # Detect package manager.
+ detect = await _exec(session, "which apk >/dev/null 2>&1 && echo apk || echo apt")
+ pkg_mgr = "apk" if b"apk" in detect.stdout else "apt"
+
+ if pkg_mgr == "apk" and tool == "gcsfuse":
+ # gcsfuse has no Alpine package; extract binary from the official .deb.
+ install_cmd = _GCSFUSE_INSTALL_ALPINE
+ elif pkg_mgr == "apk":
+ pkg = _APK_PACKAGE_NAMES.get(tool, tool)
+ install_cmd = f"apk add --no-cache {shlex.quote(pkg)}"
+ elif tool == "gcsfuse":
+ # gcsfuse is not in default Debian repos; add the Google Cloud apt source.
+ install_cmd = _GCSFUSE_INSTALL_DEBIAN
+ else:
+ install_cmd = (
+ f"apt-get update -qq && "
+ f"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {shlex.quote(tool)}"
+ )
+
+ for _attempt in range(_INSTALL_RETRIES):
+ result = await _exec(session, install_cmd, timeout=180)
+ if result.exit_code == 0:
+ return
+ raise MountConfigError(
+ message=f"failed to install {tool} after {_INSTALL_RETRIES} attempts",
+ context={"tool": tool, "exit_code": result.exit_code},
+ )
+
+
+async def _ensure_tool(session: BaseSandboxSession, tool: str) -> None:
+ """Check if a tool is available; install it if not."""
+ check = await _exec(session, f"which {shlex.quote(tool)} >/dev/null 2>&1")
+ if check.exit_code == 0:
+ return
+ await _install_tool(session, tool)
+
+
+async def _mount_s3(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None:
+ """Mount an S3 or R2 bucket using s3fs-fuse."""
+ await _ensure_tool(session, "s3fs")
+
+ # Write credentials to a temp file.
+ cred_path = f"/tmp/s3fs-passwd-{uuid.uuid4().hex[:8]}"
+ if config.access_key_id and config.secret_access_key:
+ cred_content = f"{config.access_key_id}:{config.secret_access_key}"
+ if config.session_token:
+ cred_content += f":{config.session_token}"
+ await session.exec(
+ "sh",
+ "-c",
+ f"printf %s {shlex.quote(cred_content)} > {cred_path} && chmod 600 {cred_path}",
+ )
+ else:
+ cred_path = ""
+
+ # Build the s3fs command.
+ bucket = config.bucket
+ if config.prefix:
+ bucket = f"{config.bucket}:/{config.prefix.strip('/')}"
+ mount_path = shlex.quote(config.mount_path)
+
+ opts = ["allow_other", "nonempty"]
+ if cred_path:
+ opts.append(f"passwd_file={cred_path}")
+ else:
+ opts.append("public_bucket=1")
+
+ if config.endpoint_url:
+ opts.append(f"url={config.endpoint_url}")
+ elif config.region:
+ opts.append(f"url=https://s3.{config.region}.amazonaws.com")
+ opts.append(f"endpoint={config.region}")
+
+ if config.provider == "r2":
+ opts.append("sigv4")
+
+ if config.read_only:
+ opts.append("ro")
+
+ opts_str = ",".join(opts)
+ cmd = f"s3fs {shlex.quote(bucket)} {mount_path} -o {opts_str}"
+
+ try:
+ await _exec(session, f"mkdir -p {mount_path}")
+ result = await _exec(session, cmd, timeout=60)
+ if result.exit_code != 0:
+ stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
+ raise MountConfigError(
+ message="s3fs mount failed",
+ context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr},
+ )
+ finally:
+ # Clean up credentials file.
+ if cred_path:
+ await _exec(session, f"rm -f {cred_path}")
+
+
+async def _mount_gcs(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None:
+ """Mount a GCS bucket using gcsfuse."""
+ await _ensure_tool(session, "gcsfuse")
+
+ mount_path = shlex.quote(config.mount_path)
+ bucket = shlex.quote(config.bucket)
+
+ # Write service account key if provided.
+ key_path = ""
+ if config.service_account_key:
+ key_path = f"/tmp/gcs-creds-{uuid.uuid4().hex[:8]}.json"
+ await session.exec(
+ "sh",
+ "-c",
+ f"printf %s {shlex.quote(config.service_account_key)} "
+ f"> {key_path} && chmod 600 {key_path}",
+ )
+
+ opts: list[str] = []
+ if key_path:
+ opts.append(f"--key-file={key_path}")
+ else:
+ opts.append("--anonymous-access")
+
+ if config.read_only:
+ opts.append("-o ro")
+
+ if config.prefix:
+ opts.append(f"--only-dir={config.prefix.strip('/')}")
+
+ opts_str = " ".join(opts)
+ cmd = f"gcsfuse {opts_str} {bucket} {mount_path}"
+
+ try:
+ await _exec(session, f"mkdir -p {mount_path}")
+ result = await _exec(session, cmd, timeout=60)
+ if result.exit_code != 0:
+ stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
+ raise MountConfigError(
+ message="gcsfuse mount failed",
+ context={"cmd": cmd, "exit_code": result.exit_code, "stderr": stderr},
+ )
+ finally:
+ if key_path:
+ await _exec(session, f"rm -f {key_path}")
+
+
+async def _mount_bucket(session: BaseSandboxSession, config: BlaxelCloudBucketMountConfig) -> None:
+ """Dispatch to the appropriate FUSE mount function."""
+ if config.provider in ("s3", "r2"):
+ await _mount_s3(session, config)
+ elif config.provider == "gcs":
+ await _mount_gcs(session, config)
+ else:
+ raise MountConfigError(
+ message=f"unsupported mount provider: {config.provider}",
+ context={"provider": config.provider},
+ )
+
+
+async def _unmount_bucket(session: BaseSandboxSession, mount_path: str) -> None:
+ """Unmount a FUSE mount point. Tries fusermount first, falls back to umount."""
+ path = shlex.quote(mount_path)
+ # Try fusermount (FUSE-aware).
+ result = await _exec(session, f"fusermount -u {path}")
+ if result.exit_code == 0:
+ return
+ logger.debug("fusermount failed for %s (exit %d), trying umount", mount_path, result.exit_code)
+ # Fallback to regular umount.
+ result = await _exec(session, f"umount {path}")
+ if result.exit_code == 0:
+ return
+ logger.debug("umount failed for %s (exit %d), trying lazy umount", mount_path, result.exit_code)
+ # Last resort: lazy unmount.
+ result = await _exec(session, f"umount -l {path}")
+ if result.exit_code != 0:
+ logger.warning(
+ "all unmount attempts failed for %s (last exit %d)", mount_path, result.exit_code
+ )
+
+
+# ---------------------------------------------------------------------------
+# Blaxel Drive mount strategy
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class BlaxelDriveMountConfig:
+ """Configuration for mounting a Blaxel Drive into a sandbox.
+
+ Blaxel Drives are persistent network volumes managed by the Blaxel platform.
+ Data written to a drive persists across sandbox sessions and can be shared
+ between multiple sandboxes.
+
+ See https://docs.blaxel.ai/Agent-drive/Overview for details.
+ """
+
+ drive_name: str
+ mount_path: str
+ drive_path: str = "/"
+ read_only: bool = False
+
+
+class BlaxelDriveMount(Mount):
+ """A concrete Mount entry for Blaxel Drives.
+
+ Carries the drive configuration fields directly on the mount, following
+ the same pattern as ``S3Mount``, ``R2Mount``, and ``GCSMount``.
+
+ Usage::
+
+ from agents.extensions.sandbox.blaxel import (
+ BlaxelDriveMount,
+ BlaxelDriveMountStrategy,
+ )
+
+ mount = BlaxelDriveMount(
+ drive_name="my-drive",
+ drive_mount_path="/data",
+ mount_strategy=BlaxelDriveMountStrategy(),
+ )
+ """
+
+ type: Literal["blaxel_drive_mount"] = "blaxel_drive_mount"
+ drive_name: str
+ drive_mount_path: str = ""
+ drive_path: str = "/"
+ drive_read_only: bool = False
+
+ def model_post_init(self, context: object, /) -> None:
+ """Validate the mount strategy without requiring in-container or docker patterns.
+
+ Blaxel drives use a platform-level API (``POST /drives/mount``) rather
+ than in-container FUSE tools or Docker volume drivers, so the base
+ ``Mount`` validation for those patterns does not apply.
+ """
+ _ = context
+ default_permissions = Permissions(
+ owner=FileMode.ALL,
+ group=FileMode.READ | FileMode.EXEC,
+ other=FileMode.READ | FileMode.EXEC,
+ )
+ if (
+ self.permissions.owner != default_permissions.owner
+ or self.permissions.group != default_permissions.group
+ or self.permissions.other != default_permissions.other
+ ):
+ warnings.warn(
+ "Mount permissions are not enforced. "
+ "Please configure access in the cloud provider instead; "
+ "mount-level permissions can be unreliable.",
+ stacklevel=2,
+ )
+ self.permissions.owner = default_permissions.owner
+ self.permissions.group = default_permissions.group
+ self.permissions.other = default_permissions.other
+ self.permissions.directory = True
+ self.mount_strategy.validate_mount(self)
+
+
+class BlaxelDriveMountStrategy(MountStrategyBase):
+ """Mount a Blaxel Drive into a sandbox via the sandbox drives API.
+
+ This strategy uses the sandbox's ``drives`` sub-system (which wraps
+ ``POST /drives/mount`` and ``DELETE /drives/mount/``) to attach
+ and detach persistent drives.
+
+ Usage with a ``BlaxelDriveMount`` entry::
+
+ from agents.extensions.sandbox.blaxel import (
+ BlaxelDriveMount,
+ BlaxelDriveMountStrategy,
+ )
+
+ mount = BlaxelDriveMount(
+ drive_name="my-drive",
+ drive_mount_path="/data",
+ mount_strategy=BlaxelDriveMountStrategy(),
+ )
+ """
+
+ type: Literal["blaxel_drive"] = "blaxel_drive"
+
+ def validate_mount(self, mount: Mount) -> None:
+ if not isinstance(mount, BlaxelDriveMount):
+ raise MountConfigError(
+ message=("BlaxelDriveMountStrategy requires a BlaxelDriveMount entry"),
+ context={"mount_type": mount.type},
+ )
+
+ async def activate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> list[MaterializedFile]:
+ _assert_blaxel_session(session)
+ _ = base_dir
+ config = self._resolve_config(mount, session, dest)
+ sandbox = getattr(session, "_sandbox", None)
+ if sandbox is None:
+ raise MountConfigError(
+ message="cannot access sandbox instance for drive mount",
+ context={"session_type": type(session).__name__},
+ )
+ await _attach_drive(sandbox, config)
+ return []
+
+ async def deactivate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> None:
+ _assert_blaxel_session(session)
+ _ = base_dir
+ config = self._resolve_config(mount, session, dest)
+ sandbox = getattr(session, "_sandbox", None)
+ if sandbox is not None:
+ await _detach_drive(sandbox, config.mount_path)
+
+ async def teardown_for_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_blaxel_session(session)
+ effective_path = self._effective_mount_path(mount, path)
+ sandbox = getattr(session, "_sandbox", None)
+ if sandbox is not None:
+ await _detach_drive(sandbox, effective_path)
+
+ async def restore_after_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_blaxel_session(session)
+ effective_path = self._effective_mount_path(mount, path)
+ config = self._resolve_config_from_source(mount, effective_path)
+ sandbox = getattr(session, "_sandbox", None)
+ if sandbox is None:
+ raise MountConfigError(
+ message="cannot access sandbox instance for drive remount",
+ context={"session_type": type(session).__name__},
+ )
+ await _attach_drive(sandbox, config)
+
+ def build_docker_volume_driver_config(
+ self,
+ mount: Mount,
+ ) -> tuple[str, dict[str, str], bool] | None:
+ _ = mount
+ return None
+
+ @staticmethod
+ def _resolve_config(
+ mount: Mount, session: BaseSandboxSession, dest: Path
+ ) -> BlaxelDriveMountConfig:
+ if not isinstance(mount, BlaxelDriveMount):
+ raise MountConfigError(
+ message="BlaxelDriveMountStrategy requires a BlaxelDriveMount entry",
+ context={"mount_type": mount.type},
+ )
+ mount_path = mount.drive_mount_path or str(mount._resolve_mount_path(session, dest))
+ return BlaxelDriveMountConfig(
+ drive_name=mount.drive_name,
+ mount_path=mount_path,
+ drive_path=mount.drive_path,
+ read_only=mount.drive_read_only,
+ )
+
+ @staticmethod
+ def _effective_mount_path(mount: Mount, fallback: Path) -> str:
+ """Return the actual mount path, preferring ``drive_mount_path`` over the manifest path."""
+ if isinstance(mount, BlaxelDriveMount) and mount.drive_mount_path:
+ return mount.drive_mount_path
+ return str(fallback)
+
+ @staticmethod
+ def _resolve_config_from_source(mount: Mount, mount_path: str) -> BlaxelDriveMountConfig:
+ if not isinstance(mount, BlaxelDriveMount):
+ raise MountConfigError(
+ message="BlaxelDriveMountStrategy requires a BlaxelDriveMount entry",
+ context={"mount_type": mount.type},
+ )
+ return BlaxelDriveMountConfig(
+ drive_name=mount.drive_name,
+ mount_path=mount_path,
+ drive_path=mount.drive_path,
+ read_only=mount.drive_read_only,
+ )
+
+
+async def _attach_drive(sandbox: Any, config: BlaxelDriveMountConfig) -> None:
+ """Attach a Blaxel Drive to a sandbox via ``sandbox.drives.mount()``."""
+ drives = getattr(sandbox, "drives", None)
+ if drives is not None and hasattr(drives, "mount"):
+ try:
+ await drives.mount(config.drive_name, config.mount_path, config.drive_path)
+ except Exception as e:
+ raise MountConfigError(
+ message=f"drive mount failed for {config.drive_name}",
+ context={
+ "drive_name": config.drive_name,
+ "mount_path": config.mount_path,
+ "detail": str(e),
+ },
+ ) from e
+ return
+ raise MountConfigError(
+ message="sandbox does not expose a drives API",
+ context={"sandbox_type": type(sandbox).__name__},
+ )
+
+
+async def _detach_drive(sandbox: Any, mount_path: str) -> None:
+ """Detach a Blaxel Drive from a sandbox (best-effort)."""
+ drives = getattr(sandbox, "drives", None)
+ if drives is not None and hasattr(drives, "unmount"):
+ try:
+ await drives.unmount(mount_path)
+ except Exception as e:
+ logger.warning("drive detach failed for %s (non-fatal): %s", mount_path, e)
+
+
+__all__ = [
+ "BlaxelCloudBucketMountConfig",
+ "BlaxelCloudBucketMountStrategy",
+ "BlaxelDriveMountConfig",
+ "BlaxelDriveMountStrategy",
+]
diff --git a/src/agents/extensions/sandbox/blaxel/sandbox.py b/src/agents/extensions/sandbox/blaxel/sandbox.py
new file mode 100644
index 00000000..6b48c270
--- /dev/null
+++ b/src/agents/extensions/sandbox/blaxel/sandbox.py
@@ -0,0 +1,1189 @@
+"""
+Blaxel sandbox (https://blaxel.ai) implementation.
+
+This module provides a Blaxel-backed sandbox client/session implementation backed by
+``blaxel.core.sandbox.SandboxInstance``.
+
+The ``blaxel`` dependency is optional, so package-level exports should guard imports of this
+module. Within this module, Blaxel SDK imports are lazy so users without the extra can still
+import the package.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import io
+import json
+import logging
+import math
+import os
+import shlex
+import time
+import uuid
+from collections import deque
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Literal, cast
+from urllib.parse import urlsplit
+
+from pydantic import BaseModel, Field
+
+from ....sandbox.entries import Mount
+from ....sandbox.errors import (
+ ExecTimeoutError,
+ ExecTransportError,
+ ExposedPortUnavailableError,
+ WorkspaceArchiveReadError,
+ WorkspaceArchiveWriteError,
+ WorkspaceReadNotFoundError,
+ WorkspaceWriteTypeError,
+)
+from ....sandbox.manifest import Manifest
+from ....sandbox.session import SandboxSession, SandboxSessionState
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+from ....sandbox.session.dependencies import Dependencies
+from ....sandbox.session.manager import Instrumentation
+from ....sandbox.session.pty_types import (
+ PTY_PROCESSES_MAX,
+ PTY_PROCESSES_WARNING,
+ PtyExecUpdate,
+ allocate_pty_process_id,
+ clamp_pty_yield_time_ms,
+ process_id_to_prune_from_meta,
+ resolve_pty_write_yield_time_ms,
+ truncate_text_by_tokens,
+)
+from ....sandbox.session.sandbox_client import BaseSandboxClient
+from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
+from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
+from ....sandbox.util.retry import (
+ TRANSIENT_HTTP_STATUS_CODES,
+ exception_chain_contains_type,
+ exception_chain_has_status_code,
+ retry_async,
+)
+from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
+
+DEFAULT_BLAXEL_WORKSPACE_ROOT = "/workspace"
+logger = logging.getLogger(__name__)
+
+
+def _import_blaxel_sdk() -> Any:
+ """Lazily import SandboxInstance from the Blaxel SDK, raising a clear error if missing."""
+ try:
+ from blaxel.core.sandbox import SandboxInstance
+
+ return SandboxInstance
+ except ImportError as e:
+ raise ImportError(
+ "BlaxelSandboxClient requires the optional `blaxel` dependency.\n"
+ "Install the Blaxel extra before using this sandbox backend."
+ ) from e
+
+
+def _import_aiohttp() -> Any:
+ """Lazily import aiohttp for WebSocket PTY support."""
+ try:
+ import aiohttp
+
+ return aiohttp
+ except ImportError as e:
+ raise ImportError(
+ "PTY support for BlaxelSandboxSession requires the `aiohttp` package.\n"
+ "Install it with: pip install aiohttp"
+ ) from e
+
+
+def _has_aiohttp() -> bool:
+ """Check whether aiohttp is available without raising."""
+ try:
+ import aiohttp # noqa: F401
+
+ return True
+ except ImportError:
+ return False
+
+
+def _import_sandbox_api_error() -> type[BaseException] | None:
+ """Best-effort import of ``SandboxAPIError`` from the Blaxel SDK.
+
+ Returns the exception class or ``None`` if the SDK is not installed.
+ ``SandboxAPIError`` carries a ``status_code`` attribute that lets us
+ classify errors (e.g. 404 for not-found, 408/504 for timeouts).
+ """
+ try:
+ from blaxel.core.sandbox import SandboxAPIError
+
+ return cast(type[BaseException], SandboxAPIError)
+ except Exception:
+ return None
+
+
+class BlaxelTimeouts(BaseModel):
+ """Timeout configuration for Blaxel sandbox operations."""
+
+ model_config = {"frozen": True}
+
+ exec_timeout_s: float = Field(default=300.0, ge=1)
+ cleanup_s: float = Field(default=30.0, ge=1)
+ file_upload_s: float = Field(default=1800.0, ge=1)
+ file_download_s: float = Field(default=1800.0, ge=1)
+ workspace_tar_s: float = Field(default=300.0, ge=1)
+ fast_op_s: float = Field(default=30.0, ge=1)
+
+
+@dataclass(frozen=True)
+class BlaxelSandboxClientOptions:
+ """Client options for the Blaxel sandbox."""
+
+ image: str | None = None
+ memory: int | None = None
+ region: str | None = None
+ ports: tuple[dict[str, Any], ...] | None = None
+ env_vars: dict[str, str] | None = None
+ labels: dict[str, str] | None = None
+ ttl: str | None = None
+ name: str | None = None
+ pause_on_exit: bool = False
+ timeouts: BlaxelTimeouts | dict[str, object] | None = None
+ exposed_port_public: bool = True
+ exposed_port_url_ttl_s: int = 3600
+
+
+class BlaxelSandboxSessionState(SandboxSessionState):
+ """Serializable state for a Blaxel-backed session."""
+
+ type: Literal["blaxel"] = "blaxel"
+ sandbox_name: str
+ image: str | None = None
+ memory: int | None = None
+ region: str | None = None
+ base_env_vars: dict[str, str] = Field(default_factory=dict)
+ labels: dict[str, str] = Field(default_factory=dict)
+ ttl: str | None = None
+ pause_on_exit: bool = False
+ timeouts: BlaxelTimeouts = Field(default_factory=BlaxelTimeouts)
+ sandbox_url: str | None = None
+ exposed_port_public: bool = True
+ exposed_port_url_ttl_s: int = 3600
+
+
+# ---------------------------------------------------------------------------
+# PTY session entry
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class _BlaxelPtySessionEntry:
+ ws_session_id: str
+ ws: Any # aiohttp.ClientWebSocketResponse
+ http_session: Any # aiohttp.ClientSession
+ tty: bool = True
+ output_chunks: deque[bytes] = field(default_factory=deque)
+ output_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ output_notify: asyncio.Event = field(default_factory=asyncio.Event)
+ last_used: float = field(default_factory=time.monotonic)
+ done: bool = False
+ exit_code: int | None = None
+ reader_task: asyncio.Task[None] | None = None
+
+
+# ---------------------------------------------------------------------------
+# Sandbox session
+# ---------------------------------------------------------------------------
+
+
+class BlaxelSandboxSession(BaseSandboxSession):
+ """Blaxel-backed sandbox session implementation."""
+
+ state: BlaxelSandboxSessionState
+ _sandbox: Any # SandboxInstance
+ _token: str | None
+ _pty_lock: asyncio.Lock
+ _pty_sessions: dict[int, _BlaxelPtySessionEntry]
+ _reserved_pty_process_ids: set[int]
+
+ def __init__(
+ self,
+ *,
+ state: BlaxelSandboxSessionState,
+ sandbox: Any,
+ token: str | None = None,
+ ) -> None:
+ self.state = state
+ self._sandbox = sandbox
+ self._token = token
+ self._pty_lock = asyncio.Lock()
+ self._pty_sessions = {}
+ self._reserved_pty_process_ids = set()
+
+ @classmethod
+ def from_state(
+ cls,
+ state: BlaxelSandboxSessionState,
+ *,
+ sandbox: Any,
+ token: str | None = None,
+ ) -> BlaxelSandboxSession:
+ return cls(state=state, sandbox=sandbox, token=token)
+
+ @property
+ def sandbox_name(self) -> str:
+ return self.state.sandbox_name
+
+ # -- exposed ports -------------------------------------------------------
+
+ def _assert_exposed_port_configured(self, port: int) -> None:
+ # Blaxel previews can be created for any port on demand; no pre-declaration needed.
+ pass
+
+ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
+ is_public = self.state.exposed_port_public
+ try:
+ preview = await self._sandbox.previews.create_if_not_exists(
+ {
+ "metadata": {"name": f"port-{port}"},
+ "spec": {"port": port, "public": is_public},
+ }
+ )
+ except Exception as e:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "blaxel", "detail": "preview_creation_failed"},
+ cause=e,
+ ) from e
+
+ url = _extract_preview_url(preview)
+ if not isinstance(url, str) or not url:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "blaxel", "detail": "invalid_preview_url", "url": url},
+ )
+
+ # For private previews, create a time-limited token.
+ query = ""
+ if not is_public:
+ try:
+ expires_at = datetime.now(timezone.utc) + timedelta(
+ seconds=self.state.exposed_port_url_ttl_s,
+ )
+ token = await preview.tokens.create(expires_at)
+ token_value = getattr(token, "value", None) or getattr(token, "token", None)
+ if isinstance(token_value, str) and token_value:
+ query = f"bl_preview_token={token_value}"
+ except Exception as e:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "blaxel", "detail": "preview_token_creation_failed"},
+ cause=e,
+ ) from e
+
+ try:
+ split = urlsplit(url)
+ host = split.hostname
+ if host is None:
+ raise ValueError("missing hostname")
+ port_value = split.port or (443 if split.scheme == "https" else 80)
+ return ExposedPortEndpoint(
+ host=host,
+ port=port_value,
+ tls=split.scheme == "https",
+ query=query,
+ )
+ except Exception as e:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "blaxel", "detail": "url_parse_failed", "url": url},
+ cause=e,
+ ) from e
+
+ # -- lifecycle -----------------------------------------------------------
+
+ async def start(self) -> None:
+ # When resuming a paused sandbox, _skip_start is set by the client to
+ # avoid reapplying the full manifest over files that may have changed
+ # while the sandbox was paused.
+ if getattr(self, "_skip_start", False):
+ return
+
+ # Ensure workspace root exists before BaseSandboxSession.start() materializes
+ # the manifest. Blaxel base images run as root and do not ship a pre-created
+ # workspace directory.
+ root = self.state.manifest.root
+ try:
+ await self._sandbox.process.exec(
+ {
+ "command": f"mkdir -p {shlex.quote(root)}",
+ "working_dir": "/",
+ "wait_for_completion": True,
+ "timeout": 10000,
+ }
+ )
+ except Exception as e:
+ logger.debug("workspace root mkdir failed (will retry during materialization): %s", e)
+ await super().start()
+
+ async def stop(self) -> None:
+ await super().stop()
+
+ async def shutdown(self) -> None:
+ await self.pty_terminate_all()
+ try:
+ if not self.state.pause_on_exit:
+ await self._sandbox.delete()
+ # When pause_on_exit is True the sandbox is kept alive. Blaxel
+ # automatically resumes it on the next connection.
+ except Exception as e:
+ logger.warning("sandbox delete failed during shutdown: %s", e)
+
+ # -- file operations -----------------------------------------------------
+
+ async def mkdir(
+ self,
+ path: Path | str,
+ *,
+ parents: bool = False,
+ user: str | User | None = None,
+ ) -> None:
+ if user is not None:
+ path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
+ else:
+ path = self.normalize_path(path)
+ if path == Path("/"):
+ return
+ try:
+ await self._sandbox.fs.mkdir(str(path))
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(
+ path=path,
+ context={"reason": "mkdir_failed"},
+ cause=e,
+ ) from e
+
+ async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
+ path = Path(path)
+ if user is not None:
+ await self._check_read_with_exec(path, user=user)
+
+ workspace_path = self.normalize_path(path)
+ try:
+ data: Any = await self._sandbox.fs.read_binary(str(workspace_path))
+ if isinstance(data, str):
+ data = data.encode("utf-8")
+ return io.BytesIO(bytes(data))
+ except Exception as e:
+ # Blaxel SDK raises ResponseError with status 404 for missing files.
+ status = getattr(e, "status", None)
+ if status is None and hasattr(e, "args") and e.args:
+ first_arg = e.args[0]
+ if isinstance(first_arg, dict):
+ status = first_arg.get("status")
+ error_str = str(e).lower()
+ if status == 404 or "not found" in error_str or "no such file" in error_str:
+ raise WorkspaceReadNotFoundError(path=path, cause=e) from e
+ raise WorkspaceArchiveReadError(path=path, cause=e) from e
+
+ async def write(
+ self,
+ path: Path | str,
+ data: io.IOBase,
+ *,
+ user: str | User | None = None,
+ ) -> None:
+ path = Path(path)
+ if user is not None:
+ await self._check_write_with_exec(path, user=user)
+
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
+
+ workspace_path = self.normalize_path(path)
+ try:
+ await self._sandbox.fs.write_binary(str(workspace_path), bytes(payload))
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
+
+ # -- exec ----------------------------------------------------------------
+
+ async def _resolved_envs(self) -> dict[str, str]:
+ manifest_envs = await self.state.manifest.environment.resolve()
+ return {**self.state.base_env_vars, **manifest_envs}
+
+ def _coerce_exec_timeout(self, timeout_s: float | None) -> float:
+ """Resolve the effective exec timeout in seconds."""
+ if timeout_s is None:
+ return float(self.state.timeouts.exec_timeout_s)
+ if timeout_s <= 0:
+ return 0.001
+ return float(timeout_s)
+
+ async def _exec_internal(
+ self,
+ *command: str | Path,
+ timeout: float | None = None,
+ ) -> ExecResult:
+ cmd_str = shlex.join(str(c) for c in command)
+ cwd = self.state.manifest.root
+ exec_timeout = self._coerce_exec_timeout(timeout)
+ timeout_ms = int(max(1, math.ceil(exec_timeout)) * 1000)
+
+ # Resolve manifest + base env vars and prepend them so the executed
+ # process sees them.
+ envs = await self._resolved_envs()
+ if envs:
+ env_prefix = " ".join(f"{shlex.quote(k)}={shlex.quote(v)}" for k, v in envs.items())
+ cmd_str = f"env {env_prefix} {cmd_str}"
+
+ try:
+ result = await asyncio.wait_for(
+ self._sandbox.process.exec(
+ {
+ "command": cmd_str,
+ "working_dir": cwd,
+ "wait_for_completion": True,
+ "timeout": timeout_ms,
+ }
+ ),
+ timeout=exec_timeout,
+ )
+
+ exit_code = int(getattr(result, "exit_code", 0) or 0)
+ # Blaxel ProcessResponse uses .stdout / .stderr / .logs attributes. Prefer
+ # split streams when available, and only fall back to logs/output for older SDKs.
+ has_split_streams = hasattr(result, "stdout") or hasattr(result, "stderr")
+ stdout = str(getattr(result, "stdout", "") or "")
+ stderr = str(getattr(result, "stderr", "") or "")
+ fallback = str(getattr(result, "logs", "") or getattr(result, "output", "") or "")
+ stdout_bytes = stdout.encode("utf-8", errors="replace")
+ stderr_bytes = stderr.encode("utf-8", errors="replace")
+
+ if has_split_streams:
+ return ExecResult(stdout=stdout_bytes, stderr=stderr_bytes, exit_code=exit_code)
+
+ fallback_bytes = fallback.encode("utf-8", errors="replace")
+ if exit_code == 0:
+ return ExecResult(stdout=fallback_bytes, stderr=b"", exit_code=exit_code)
+ return ExecResult(stdout=b"", stderr=fallback_bytes, exit_code=exit_code)
+ except asyncio.TimeoutError as e:
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ except (ExecTimeoutError, ExecTransportError):
+ raise
+ except Exception as e:
+ api_error_cls = _import_sandbox_api_error()
+ if api_error_cls is not None and isinstance(e, api_error_cls):
+ status = getattr(e, "status_code", None)
+ if status in (408, 504):
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ raise ExecTransportError(command=command, cause=e) from e
+
+ # -- running check -------------------------------------------------------
+
+ async def running(self) -> bool:
+ try:
+ await asyncio.wait_for(self._sandbox.fs.ls("/"), timeout=10.0)
+ return True
+ except Exception as e:
+ logger.debug("sandbox health check failed: %s", e)
+ return False
+
+ # -- workspace persistence -----------------------------------------------
+
+ def _tar_exclude_args(self) -> list[str]:
+ excludes: list[str] = []
+ for rel in sorted(self._persist_workspace_skip_relpaths(), key=lambda p: p.as_posix()):
+ rel_posix = rel.as_posix().lstrip("/")
+ if not rel_posix or rel_posix in {".", "/"}:
+ continue
+ excludes.append(f"--exclude={shlex.quote(rel_posix)}")
+ excludes.append(f"--exclude={shlex.quote(f'./{rel_posix}')}")
+ return excludes
+
+ @retry_async(
+ retry_if=lambda exc, self: (
+ exception_chain_contains_type(exc, (asyncio.TimeoutError,))
+ or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES)
+ )
+ )
+ async def persist_workspace(self) -> io.IOBase:
+ root = Path(self.state.manifest.root)
+ tar_path = f"/tmp/bl-persist-{self.state.session_id.hex}.tar"
+ excludes = " ".join(self._tar_exclude_args())
+ tar_cmd = (
+ f"tar {excludes} -C {shlex.quote(str(root))} -cf {shlex.quote(tar_path)} ."
+ ).strip()
+
+ unmounted_mounts: list[tuple[Mount, Path]] = []
+ unmount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ unmount_error = WorkspaceArchiveReadError(path=root, cause=e)
+ break
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ snapshot_error: WorkspaceArchiveReadError | None = None
+ raw: bytes | None = None
+ if unmount_error is None:
+ try:
+ result = await self._exec_internal(
+ "sh", "-c", tar_cmd, timeout=self.state.timeouts.workspace_tar_s
+ )
+ if result.exit_code != 0:
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "tar_failed",
+ "output": result.stderr.decode("utf-8", errors="replace"),
+ },
+ )
+ raw_data: Any = await self._sandbox.fs.read_binary(tar_path)
+ if isinstance(raw_data, str):
+ raw_data = raw_data.encode("utf-8")
+ raw = bytes(raw_data)
+ except WorkspaceArchiveReadError as e:
+ snapshot_error = e
+ except Exception as e:
+ snapshot_error = WorkspaceArchiveReadError(path=root, cause=e)
+ finally:
+ try:
+ await self._exec_internal(
+ "rm", "-f", "--", tar_path, timeout=self.state.timeouts.cleanup_s
+ )
+ except Exception as e:
+ logger.debug("persist cleanup rm failed (non-fatal): %s", e)
+
+ remount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ if remount_error is None:
+ remount_error = WorkspaceArchiveReadError(path=root, cause=e)
+
+ if remount_error is not None:
+ raise remount_error
+ if unmount_error is not None:
+ raise unmount_error
+ if snapshot_error is not None:
+ raise snapshot_error
+
+ assert raw is not None
+ return io.BytesIO(raw)
+
+ async def hydrate_workspace(self, data: io.IOBase) -> None:
+ root = self.state.manifest.root
+ tar_path = f"/tmp/bl-hydrate-{self.state.session_id.hex}.tar"
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=Path(tar_path), actual_type=type(payload).__name__)
+
+ try:
+ validate_tar_bytes(bytes(payload))
+ except UnsafeTarMemberError as e:
+ raise WorkspaceArchiveWriteError(
+ path=Path(root),
+ context={
+ "reason": "unsafe_or_invalid_tar",
+ "member": e.member,
+ "detail": str(e),
+ },
+ cause=e,
+ ) from e
+
+ try:
+ await self.mkdir(root, parents=True)
+ await self._sandbox.fs.write_binary(tar_path, bytes(payload))
+ result = await self._exec_internal(
+ "sh",
+ "-c",
+ f"tar -C {shlex.quote(root)} -xf {shlex.quote(tar_path)}",
+ timeout=self.state.timeouts.workspace_tar_s,
+ )
+ if result.exit_code != 0:
+ raise WorkspaceArchiveWriteError(
+ path=Path(root),
+ context={
+ "reason": "tar_extract_failed",
+ "output": result.stderr.decode("utf-8", errors="replace"),
+ },
+ )
+ except WorkspaceArchiveWriteError:
+ raise
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=Path(root), cause=e) from e
+ finally:
+ try:
+ await self._exec_internal(
+ "rm", "-f", "--", tar_path, timeout=self.state.timeouts.cleanup_s
+ )
+ except Exception as e:
+ logger.debug("hydrate cleanup rm failed (non-fatal): %s", e)
+
+ # -- PTY -----------------------------------------------------------------
+
+ def supports_pty(self) -> bool:
+ return self.state.sandbox_url is not None and self._token is not None and _has_aiohttp()
+
+ 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:
+ aiohttp = _import_aiohttp()
+ sanitized = self._prepare_exec_command(*command, shell=shell, user=user)
+ cmd_str = shlex.join(str(part) for part in sanitized)
+ cwd = self.state.manifest.root
+ exec_timeout = timeout if timeout is not None else self.state.timeouts.exec_timeout_s
+
+ ws_session_id = f"pty-{uuid.uuid4().hex[:12]}"
+ ws_url = _build_ws_url(
+ sandbox_url=self.state.sandbox_url or "",
+ token=self._token or "",
+ session_id=ws_session_id,
+ cwd=cwd,
+ )
+
+ entry = _BlaxelPtySessionEntry(
+ ws_session_id=ws_session_id,
+ ws=None,
+ http_session=None,
+ tty=True,
+ )
+
+ registered = False
+ pruned: _BlaxelPtySessionEntry | None = None
+ process_count = 0
+
+ try:
+ http_session = aiohttp.ClientSession()
+ entry.http_session = http_session
+ ws = await asyncio.wait_for(
+ http_session.ws_connect(ws_url),
+ timeout=exec_timeout,
+ )
+ entry.ws = ws
+
+ # Start background reader.
+ entry.reader_task = asyncio.create_task(self._pty_ws_reader(entry))
+
+ # Send command.
+ await asyncio.wait_for(
+ ws.send_str(json.dumps({"type": "input", "data": cmd_str + "\n"})),
+ timeout=self.state.timeouts.fast_op_s,
+ )
+
+ async with self._pty_lock:
+ process_id = allocate_pty_process_id(self._reserved_pty_process_ids)
+ self._reserved_pty_process_ids.add(process_id)
+ pruned = self._prune_pty_sessions_if_needed()
+ self._pty_sessions[process_id] = entry
+ process_count = len(self._pty_sessions)
+ registered = True
+ except asyncio.TimeoutError as e:
+ if not registered:
+ await self._terminate_pty_entry(entry)
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ except Exception as e:
+ if not registered:
+ await self._terminate_pty_entry(entry)
+ raise ExecTransportError(command=command, cause=e) from e
+
+ if pruned is not None:
+ await self._terminate_pty_entry(pruned)
+
+ if process_count >= PTY_PROCESSES_WARNING:
+ logger.warning(
+ "PTY process count reached warning threshold: %s active sessions",
+ process_count,
+ )
+
+ yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms),
+ max_output_tokens=max_output_tokens,
+ )
+ return await self._finalize_pty_update(
+ process_id=process_id,
+ entry=entry,
+ output=output,
+ original_token_count=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:
+ async with self._pty_lock:
+ entry = self._resolve_pty_session_entry(
+ pty_processes=self._pty_sessions,
+ session_id=session_id,
+ )
+
+ if chars and entry.ws is not None:
+ await asyncio.wait_for(
+ entry.ws.send_str(json.dumps({"type": "input", "data": chars})),
+ timeout=self.state.timeouts.fast_op_s,
+ )
+ await asyncio.sleep(0.1)
+
+ yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=resolve_pty_write_yield_time_ms(
+ yield_time_ms=yield_time_ms, input_empty=chars == ""
+ ),
+ max_output_tokens=max_output_tokens,
+ )
+ entry.last_used = time.monotonic()
+ return await self._finalize_pty_update(
+ process_id=session_id,
+ entry=entry,
+ output=output,
+ original_token_count=original_token_count,
+ )
+
+ async def pty_terminate_all(self) -> None:
+ async with self._pty_lock:
+ entries = list(self._pty_sessions.values())
+ self._pty_sessions.clear()
+ self._reserved_pty_process_ids.clear()
+ for entry in entries:
+ await self._terminate_pty_entry(entry)
+
+ # -- PTY internals -------------------------------------------------------
+
+ async def _pty_ws_reader(self, entry: _BlaxelPtySessionEntry) -> None:
+ """Background task that reads WebSocket messages into *entry.output_chunks*."""
+ try:
+ aiohttp = _import_aiohttp()
+ async for msg in entry.ws:
+ if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY):
+ try:
+ raw_text = (
+ msg.data
+ if isinstance(msg.data, str)
+ else msg.data.decode("utf-8", errors="replace")
+ )
+ data = json.loads(raw_text)
+ msg_type = data.get("type", "") or data.get("Type", "")
+ if msg_type == "output":
+ raw = (data.get("data", "") or data.get("Data", "")).encode(
+ "utf-8", errors="replace"
+ )
+ async with entry.output_lock:
+ entry.output_chunks.append(raw)
+ entry.output_notify.set()
+ elif msg_type == "error":
+ raw = (data.get("data", "") or data.get("Data", "")).encode(
+ "utf-8", errors="replace"
+ )
+ async with entry.output_lock:
+ entry.output_chunks.append(raw)
+ entry.done = True
+ entry.output_notify.set()
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ logger.debug("PTY ws reader: ignoring malformed message")
+ elif msg.type in (
+ aiohttp.WSMsgType.ERROR,
+ aiohttp.WSMsgType.CLOSE,
+ aiohttp.WSMsgType.CLOSING,
+ ):
+ break
+ except Exception as e:
+ logger.debug("PTY ws reader terminated with error: %s", e)
+ finally:
+ entry.done = True
+ entry.output_notify.set()
+
+ async def _collect_pty_output(
+ self,
+ *,
+ entry: _BlaxelPtySessionEntry,
+ yield_time_ms: int,
+ max_output_tokens: int | None,
+ ) -> tuple[bytes, int | None]:
+ deadline = time.monotonic() + (yield_time_ms / 1000)
+ output = bytearray()
+
+ while True:
+ async with entry.output_lock:
+ while entry.output_chunks:
+ output.extend(entry.output_chunks.popleft())
+
+ if time.monotonic() >= deadline:
+ break
+ if entry.done:
+ async with entry.output_lock:
+ while entry.output_chunks:
+ output.extend(entry.output_chunks.popleft())
+ break
+
+ remaining_s = deadline - time.monotonic()
+ if remaining_s <= 0:
+ break
+ try:
+ await asyncio.wait_for(entry.output_notify.wait(), timeout=remaining_s)
+ except asyncio.TimeoutError:
+ break
+ entry.output_notify.clear()
+
+ text = output.decode("utf-8", errors="replace")
+ truncated, original_token_count = truncate_text_by_tokens(text, max_output_tokens)
+ return truncated.encode("utf-8", errors="replace"), original_token_count
+
+ async def _finalize_pty_update(
+ self,
+ *,
+ process_id: int,
+ entry: _BlaxelPtySessionEntry,
+ output: bytes,
+ original_token_count: int | None,
+ ) -> PtyExecUpdate:
+ exit_code = entry.exit_code if entry.done else None
+ live_process_id: int | None = process_id
+
+ if entry.done:
+ async with self._pty_lock:
+ removed = self._pty_sessions.pop(process_id, None)
+ self._reserved_pty_process_ids.discard(process_id)
+ if removed is not None:
+ await self._terminate_pty_entry(removed)
+ live_process_id = None
+
+ return PtyExecUpdate(
+ process_id=live_process_id,
+ output=output,
+ exit_code=exit_code,
+ original_token_count=original_token_count,
+ )
+
+ def _prune_pty_sessions_if_needed(self) -> _BlaxelPtySessionEntry | None:
+ if len(self._pty_sessions) < PTY_PROCESSES_MAX:
+ return None
+ meta: list[tuple[int, float, bool]] = [
+ (pid, e.last_used, e.done) for pid, e in self._pty_sessions.items()
+ ]
+ pid = process_id_to_prune_from_meta(meta)
+ if pid is None:
+ return None
+ self._reserved_pty_process_ids.discard(pid)
+ return self._pty_sessions.pop(pid, None)
+
+ async def _terminate_pty_entry(self, entry: _BlaxelPtySessionEntry) -> None:
+ try:
+ if entry.reader_task is not None and not entry.reader_task.done():
+ entry.reader_task.cancel()
+ try:
+ await entry.reader_task
+ except (asyncio.CancelledError, Exception):
+ pass
+ if entry.ws is not None:
+ try:
+ await entry.ws.close()
+ except Exception as e:
+ logger.debug("PTY ws close error (non-fatal): %s", e)
+ if entry.http_session is not None:
+ try:
+ await entry.http_session.close()
+ except Exception as e:
+ logger.debug("PTY http session close error (non-fatal): %s", e)
+ except Exception as e:
+ logger.debug("PTY entry termination error (non-fatal): %s", e)
+
+
+# ---------------------------------------------------------------------------
+# Sandbox client
+# ---------------------------------------------------------------------------
+
+
+class BlaxelSandboxClient(BaseSandboxClient["BlaxelSandboxClientOptions"]):
+ """Blaxel sandbox client managing sandbox lifecycle via the Blaxel SDK."""
+
+ backend_id = "blaxel"
+ _instrumentation: Instrumentation
+ _token: str | None
+
+ def __init__(
+ self,
+ *,
+ token: str | None = None,
+ instrumentation: Instrumentation | None = None,
+ dependencies: Dependencies | None = None,
+ ) -> None:
+ # Validate that the Blaxel SDK is importable.
+ _import_blaxel_sdk()
+ self._instrumentation = instrumentation or Instrumentation()
+ self._dependencies = dependencies
+ self._token = token or os.environ.get("BL_API_KEY")
+
+ async def create(
+ self,
+ *,
+ snapshot: SnapshotSpec | SnapshotBase | None = None,
+ manifest: Manifest | None = None,
+ options: BlaxelSandboxClientOptions,
+ ) -> SandboxSession:
+ if manifest is None:
+ manifest = Manifest(root=DEFAULT_BLAXEL_WORKSPACE_ROOT)
+
+ timeouts_in = options.timeouts
+ if isinstance(timeouts_in, BlaxelTimeouts):
+ timeouts = timeouts_in
+ elif timeouts_in is None:
+ timeouts = BlaxelTimeouts()
+ else:
+ timeouts = BlaxelTimeouts.model_validate(timeouts_in)
+
+ session_id = uuid.uuid4()
+ sandbox_name = options.name or f"agents-{session_id.hex[:12]}"
+
+ SandboxInstance = _import_blaxel_sdk()
+ create_config = _build_create_config(
+ name=sandbox_name,
+ image=options.image,
+ memory=options.memory,
+ region=options.region,
+ ports=options.ports,
+ env_vars=options.env_vars,
+ labels=options.labels,
+ ttl=options.ttl,
+ manifest=manifest,
+ )
+ blaxel_sandbox = await SandboxInstance.create_if_not_exists(create_config)
+
+ sandbox_url = _get_sandbox_url(blaxel_sandbox)
+ snapshot_instance = resolve_snapshot(snapshot, str(session_id))
+ state = BlaxelSandboxSessionState(
+ session_id=session_id,
+ manifest=manifest,
+ snapshot=snapshot_instance,
+ sandbox_name=sandbox_name,
+ image=options.image,
+ memory=options.memory,
+ region=options.region,
+ base_env_vars=dict(options.env_vars or {}),
+ labels=dict(options.labels or {}),
+ ttl=options.ttl,
+ pause_on_exit=options.pause_on_exit,
+ timeouts=timeouts,
+ sandbox_url=sandbox_url,
+ exposed_port_public=options.exposed_port_public,
+ exposed_port_url_ttl_s=options.exposed_port_url_ttl_s,
+ )
+ inner = BlaxelSandboxSession.from_state(state, sandbox=blaxel_sandbox, token=self._token)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ async def close(self) -> None:
+ """No persistent HTTP client to close; provided for API symmetry."""
+
+ async def __aenter__(self) -> BlaxelSandboxClient:
+ return self
+
+ async def __aexit__(self, *_: object) -> None:
+ await self.close()
+
+ async def delete(self, session: SandboxSession) -> SandboxSession:
+ inner = session._inner
+ if not isinstance(inner, BlaxelSandboxSession):
+ raise TypeError("BlaxelSandboxClient.delete expects a BlaxelSandboxSession")
+ try:
+ await inner.shutdown()
+ except Exception as e:
+ logger.warning("shutdown error during delete (non-fatal): %s", e)
+ return session
+
+ async def resume(
+ self,
+ state: SandboxSessionState,
+ ) -> SandboxSession:
+ """Resume a sandbox from persisted state.
+
+ When ``pause_on_exit`` is set, Blaxel automatically resumes the paused
+ sandbox on connection -- this method simply reconnects by sandbox name
+ via ``SandboxInstance.get()``. If the sandbox is no longer available
+ (e.g. it expired), a fresh one is created with the same configuration.
+ """
+ if not isinstance(state, BlaxelSandboxSessionState):
+ raise TypeError("BlaxelSandboxClient.resume expects a BlaxelSandboxSessionState")
+
+ SandboxInstance = _import_blaxel_sdk()
+ blaxel_sandbox = None
+ reconnected = False
+
+ if state.pause_on_exit:
+ try:
+ blaxel_sandbox = await SandboxInstance.get(state.sandbox_name)
+ reconnected = True
+ except Exception as e:
+ logger.debug("sandbox get() failed, will recreate: %s", e)
+
+ if not reconnected or blaxel_sandbox is None:
+ create_config = _build_create_config(
+ name=state.sandbox_name,
+ image=state.image,
+ memory=state.memory,
+ region=state.region,
+ env_vars=state.base_env_vars or None,
+ labels=state.labels or None,
+ ttl=state.ttl,
+ )
+ blaxel_sandbox = await SandboxInstance.create_if_not_exists(create_config)
+
+ sandbox_url = _get_sandbox_url(blaxel_sandbox)
+ if sandbox_url:
+ state.sandbox_url = sandbox_url
+
+ inner = BlaxelSandboxSession.from_state(state, sandbox=blaxel_sandbox, token=self._token)
+ if state.pause_on_exit and reconnected:
+ inner._skip_start = True # type: ignore[attr-defined]
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
+ return BlaxelSandboxSessionState.model_validate(payload)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _build_create_config(
+ *,
+ name: str,
+ image: str | None = None,
+ memory: int | None = None,
+ region: str | None = None,
+ ports: tuple[dict[str, Any], ...] | None = None,
+ env_vars: dict[str, str] | None = None,
+ labels: dict[str, str] | None = None,
+ ttl: str | None = None,
+ manifest: Manifest | None = None,
+) -> dict[str, Any]:
+ """Build the dict config accepted by ``SandboxInstance.create_if_not_exists``."""
+ config: dict[str, Any] = {"name": name}
+
+ if image:
+ config["image"] = image
+ if memory is not None:
+ config["memory"] = memory
+ resolved_region = region or os.environ.get("BL_REGION") or "us-pdx-1"
+ config["region"] = resolved_region
+ if labels:
+ config["labels"] = labels
+ if ttl:
+ config["ttl"] = ttl
+
+ # Pass base env vars for sandbox creation. The session will re-resolve
+ # manifest environment variables at exec time.
+ all_envs: dict[str, str] = {}
+ if env_vars:
+ all_envs.update(env_vars)
+ if all_envs:
+ config["envs"] = [{"name": k, "value": v} for k, v in all_envs.items()]
+
+ if ports:
+ config["ports"] = list(ports)
+
+ return config
+
+
+def _get_sandbox_url(sandbox_instance: Any) -> str | None:
+ """Best-effort extract the sandbox URL from a SandboxInstance."""
+ # Try sandbox_instance.sandbox.metadata.url (standard path).
+ sandbox_model = getattr(sandbox_instance, "sandbox", None)
+ if sandbox_model is not None:
+ metadata = getattr(sandbox_model, "metadata", None)
+ if metadata is not None:
+ url = getattr(metadata, "url", None)
+ if isinstance(url, str) and url:
+ return url
+ # Try direct .url attribute.
+ url = getattr(sandbox_instance, "url", None)
+ if isinstance(url, str) and url:
+ return url
+ return None
+
+
+def _extract_preview_url(preview: Any) -> str | None:
+ """Extract URL string from a preview object, trying several attribute paths.
+
+ Blaxel SDK returns a ``SandboxPreview`` whose URL lives at ``preview.spec.url``.
+ """
+ # Try spec.url first (Blaxel SDK path).
+ for nested in ("spec", "status"):
+ obj = getattr(preview, nested, None)
+ if obj is not None:
+ val = getattr(obj, "url", None)
+ if isinstance(val, str) and val:
+ return val
+ # Try direct attributes.
+ for attr in ("url", "endpoint"):
+ val = getattr(preview, attr, None)
+ if isinstance(val, str) and val:
+ return val
+ # Try the nested .preview.spec.url path.
+ inner = getattr(preview, "preview", None)
+ if inner is not None:
+ return _extract_preview_url(inner)
+ return None
+
+
+def _build_ws_url(
+ *,
+ sandbox_url: str,
+ token: str,
+ session_id: str,
+ cwd: str,
+ cols: int = 80,
+ rows: int = 24,
+) -> str:
+ """Build the WebSocket URL for a Blaxel terminal session."""
+ base = sandbox_url.rstrip("/")
+ ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
+ return (
+ f"{ws_base}/terminal/ws"
+ f"?token={token}"
+ f"&cols={cols}"
+ f"&rows={rows}"
+ f"&sessionId={session_id}"
+ f"&workingDir={cwd}"
+ )
+
+
+__all__ = [
+ "DEFAULT_BLAXEL_WORKSPACE_ROOT",
+ "BlaxelSandboxClient",
+ "BlaxelSandboxClientOptions",
+ "BlaxelSandboxSession",
+ "BlaxelSandboxSessionState",
+ "BlaxelTimeouts",
+]
diff --git a/src/agents/extensions/sandbox/cloudflare/__init__.py b/src/agents/extensions/sandbox/cloudflare/__init__.py
new file mode 100644
index 00000000..ac3c498c
--- /dev/null
+++ b/src/agents/extensions/sandbox/cloudflare/__init__.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from .mounts import CloudflareBucketMountConfig, CloudflareBucketMountStrategy
+from .sandbox import (
+ CloudflareSandboxClient,
+ CloudflareSandboxClientOptions,
+ CloudflareSandboxSession,
+ CloudflareSandboxSessionState,
+)
+
+__all__ = [
+ "CloudflareBucketMountConfig",
+ "CloudflareBucketMountStrategy",
+ "CloudflareSandboxClient",
+ "CloudflareSandboxClientOptions",
+ "CloudflareSandboxSession",
+ "CloudflareSandboxSessionState",
+]
diff --git a/src/agents/extensions/sandbox/cloudflare/mounts.py b/src/agents/extensions/sandbox/cloudflare/mounts.py
new file mode 100644
index 00000000..b6dcee22
--- /dev/null
+++ b/src/agents/extensions/sandbox/cloudflare/mounts.py
@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount
+from ....sandbox.entries.mounts.base import MountStrategyBase
+from ....sandbox.errors import MountConfigError
+from ....sandbox.materialization import MaterializedFile
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+
+CloudflareBucketProvider = Literal["r2", "s3", "gcs"]
+
+
+@dataclass(frozen=True)
+class CloudflareBucketMountConfig:
+ """Backend-neutral config for Cloudflare bucket mounts."""
+
+ bucket_name: str
+ bucket_endpoint_url: str
+ provider: CloudflareBucketProvider
+ key_prefix: str | None = None
+ credentials: dict[str, str] | None = None
+ read_only: bool = True
+
+ def to_request_options(self) -> dict[str, object]:
+ options: dict[str, object] = {
+ "endpoint": self.bucket_endpoint_url,
+ "readOnly": self.read_only,
+ }
+ if self.key_prefix is not None:
+ options["prefix"] = self.key_prefix
+ if self.credentials is not None:
+ options["credentials"] = {
+ "accessKeyId": self.credentials["access_key_id"],
+ "secretAccessKey": self.credentials["secret_access_key"],
+ }
+ return options
+
+
+class CloudflareBucketMountStrategy(MountStrategyBase):
+ type: Literal["cloudflare_bucket_mount"] = "cloudflare_bucket_mount"
+
+ def validate_mount(self, mount: Mount) -> None:
+ _ = self._build_cloudflare_bucket_mount_config(mount)
+
+ async def activate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> list[MaterializedFile]:
+ if type(session).__name__ != "CloudflareSandboxSession":
+ raise MountConfigError(
+ message="cloudflare bucket mounts are not supported by this sandbox backend",
+ context={"mount_type": mount.type, "session_type": type(session).__name__},
+ )
+ _ = base_dir
+ mount_path = mount._resolve_mount_path(session, dest)
+ config = self._build_cloudflare_bucket_mount_config(mount)
+ await session.mount_bucket( # type: ignore[attr-defined]
+ bucket=config.bucket_name,
+ mount_path=mount_path,
+ options=config.to_request_options(),
+ )
+ return []
+
+ async def deactivate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> None:
+ if type(session).__name__ != "CloudflareSandboxSession":
+ raise MountConfigError(
+ message="cloudflare bucket mounts are not supported by this sandbox backend",
+ context={"mount_type": mount.type, "session_type": type(session).__name__},
+ )
+ _ = base_dir
+ await session.unmount_bucket(mount._resolve_mount_path(session, dest)) # type: ignore[attr-defined]
+
+ async def teardown_for_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ if type(session).__name__ != "CloudflareSandboxSession":
+ raise MountConfigError(
+ message="cloudflare bucket mounts are not supported by this sandbox backend",
+ context={"mount_type": mount.type, "session_type": type(session).__name__},
+ )
+ _ = mount
+ await session.unmount_bucket(path) # type: ignore[attr-defined]
+
+ async def restore_after_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ if type(session).__name__ != "CloudflareSandboxSession":
+ raise MountConfigError(
+ message="cloudflare bucket mounts are not supported by this sandbox backend",
+ context={"mount_type": mount.type, "session_type": type(session).__name__},
+ )
+ config = self._build_cloudflare_bucket_mount_config(mount)
+ await session.mount_bucket( # type: ignore[attr-defined]
+ bucket=config.bucket_name,
+ mount_path=path,
+ options=config.to_request_options(),
+ )
+
+ def build_docker_volume_driver_config(
+ self,
+ mount: Mount,
+ ) -> tuple[str, dict[str, str], bool] | None:
+ _ = mount
+ return None
+
+ def _build_cloudflare_bucket_mount_config(
+ self,
+ mount: Mount,
+ ) -> CloudflareBucketMountConfig:
+ if isinstance(mount, S3Mount):
+ self._validate_credentials(
+ access_key_id=mount.access_key_id,
+ secret_access_key=mount.secret_access_key,
+ mount_type=mount.type,
+ )
+ if mount.session_token is not None:
+ raise MountConfigError(
+ message=(
+ "cloudflare bucket mounts do not support s3 session_token credentials"
+ ),
+ context={"type": mount.type},
+ )
+ return CloudflareBucketMountConfig(
+ bucket_name=mount.bucket,
+ bucket_endpoint_url=(
+ mount.endpoint_url
+ or (
+ f"https://s3.{mount.region}.amazonaws.com"
+ if mount.region is not None
+ else "https://s3.amazonaws.com"
+ )
+ ),
+ provider="s3",
+ key_prefix=self._normalize_prefix(mount.prefix),
+ credentials=self._build_credentials(
+ access_key_id=mount.access_key_id,
+ secret_access_key=mount.secret_access_key,
+ ),
+ read_only=mount.read_only,
+ )
+
+ if isinstance(mount, R2Mount):
+ mount._validate_credential_pair()
+ return CloudflareBucketMountConfig(
+ bucket_name=mount.bucket,
+ bucket_endpoint_url=(
+ mount.custom_domain or f"https://{mount.account_id}.r2.cloudflarestorage.com"
+ ),
+ provider="r2",
+ credentials=self._build_credentials(
+ access_key_id=mount.access_key_id,
+ secret_access_key=mount.secret_access_key,
+ ),
+ read_only=mount.read_only,
+ )
+
+ if isinstance(mount, GCSMount):
+ if not mount._use_s3_compatible_rclone():
+ raise MountConfigError(
+ message=(
+ "gcs cloudflare bucket mounts require access_id and secret_access_key"
+ ),
+ context={"type": mount.type},
+ )
+ assert mount.access_id is not None
+ assert mount.secret_access_key is not None
+ return CloudflareBucketMountConfig(
+ bucket_name=mount.bucket,
+ bucket_endpoint_url=mount.endpoint_url or "https://storage.googleapis.com",
+ provider="gcs",
+ key_prefix=self._normalize_prefix(mount.prefix),
+ credentials=self._build_credentials(
+ access_key_id=mount.access_id,
+ secret_access_key=mount.secret_access_key,
+ ),
+ read_only=mount.read_only,
+ )
+
+ raise MountConfigError(
+ message="cloudflare bucket mounts are not supported for this mount type",
+ context={"mount_type": mount.type},
+ )
+
+ @staticmethod
+ def _normalize_prefix(prefix: str | None) -> str | None:
+ if prefix is None:
+ return None
+ trimmed = prefix.strip("/")
+ if trimmed == "":
+ return "/"
+ return f"/{trimmed}/"
+
+ @staticmethod
+ def _validate_credentials(
+ *,
+ access_key_id: str | None,
+ secret_access_key: str | None,
+ mount_type: str,
+ ) -> None:
+ if (access_key_id is None) != (secret_access_key is None):
+ raise MountConfigError(
+ message=(
+ "cloudflare bucket mounts require both access_key_id and "
+ "secret_access_key when either is provided"
+ ),
+ context={"type": mount_type},
+ )
+
+ @classmethod
+ def _build_credentials(
+ cls,
+ *,
+ access_key_id: str | None,
+ secret_access_key: str | None,
+ ) -> dict[str, str] | None:
+ cls._validate_credentials(
+ access_key_id=access_key_id,
+ secret_access_key=secret_access_key,
+ mount_type="cloudflare_bucket_mount",
+ )
+ if access_key_id is None or secret_access_key is None:
+ return None
+ return {
+ "access_key_id": access_key_id,
+ "secret_access_key": secret_access_key,
+ }
diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py
new file mode 100644
index 00000000..eb979a3e
--- /dev/null
+++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py
@@ -0,0 +1,1449 @@
+"""
+Cloudflare sandbox (https://developers.cloudflare.com/sandbox/) implementation.
+
+This module provides a Cloudflare Worker-backed sandbox client/session implementation.
+The sandbox communicates with a Cloudflare Worker service over HTTP and WebSocket.
+
+Note: The `aiohttp` dependency is intended to be optional (installed via an extra),
+so package-level exports should guard imports of this module. Within this module,
+we import aiohttp normally so IDEs can resolve and navigate types.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import io
+import json
+import logging
+import os
+import shlex
+import time
+import uuid
+from collections import deque
+from contextlib import suppress
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal
+from urllib.parse import quote
+
+import aiohttp
+
+from ....sandbox.errors import (
+ ConfigurationError,
+ ErrorCode,
+ ExecTimeoutError,
+ ExecTransportError,
+ ExposedPortUnavailableError,
+ MountConfigError,
+ WorkspaceArchiveReadError,
+ WorkspaceArchiveWriteError,
+ WorkspaceReadNotFoundError,
+ WorkspaceStartError,
+ WorkspaceWriteTypeError,
+)
+from ....sandbox.manifest import Manifest
+from ....sandbox.session import SandboxSession, SandboxSessionState
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+from ....sandbox.session.dependencies import Dependencies
+from ....sandbox.session.manager import Instrumentation
+from ....sandbox.session.pty_types import (
+ PTY_PROCESSES_MAX,
+ PTY_PROCESSES_WARNING,
+ PtyExecUpdate,
+ allocate_pty_process_id,
+ clamp_pty_yield_time_ms,
+ process_id_to_prune_from_meta,
+ resolve_pty_write_yield_time_ms,
+ truncate_text_by_tokens,
+)
+from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
+from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
+from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
+from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
+from ....sandbox.util.retry import (
+ TRANSIENT_HTTP_STATUS_CODES,
+ exception_chain_has_status_code,
+ retry_async,
+)
+from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
+
+_DEFAULT_EXEC_TIMEOUT_S = 30.0
+_DEFAULT_REQUEST_TIMEOUT_S = 120.0
+
+logger = logging.getLogger(__name__)
+
+
+def _is_transient_workspace_error(exc: BaseException) -> bool:
+ """Return True if *exc* is a workspace archive error caused by a transient HTTP status."""
+ if not isinstance(exc, WorkspaceArchiveReadError | WorkspaceArchiveWriteError):
+ return False
+ status = exc.context.get("http_status")
+ return isinstance(status, int) and status in TRANSIENT_HTTP_STATUS_CODES
+
+
+@dataclass
+class _ServerSentEvent:
+ event: str = "message"
+ data: str = ""
+ id: str = ""
+ retry: int | None = None
+
+
+class _SSELineDecoder:
+ _buf: bytes
+
+ def __init__(self) -> None:
+ self._buf = b""
+
+ def decode(self, text: str) -> list[str]:
+ raw = self._buf + text.encode("utf-8")
+ self._buf = b""
+
+ lines: list[str] = []
+ i = 0
+ length = len(raw)
+ while i < length:
+ cr = raw.find(b"\r", i)
+ lf = raw.find(b"\n", i)
+
+ if cr == -1 and lf == -1:
+ self._buf = raw[i:]
+ break
+
+ if cr != -1 and (lf == -1 or cr < lf):
+ line = raw[i:cr]
+ if cr + 1 < length and raw[cr + 1 : cr + 2] == b"\n":
+ i = cr + 2
+ elif cr + 1 == length:
+ self._buf = b"\r"
+ lines.append(line.decode("utf-8"))
+ break
+ else:
+ i = cr + 1
+ lines.append(line.decode("utf-8"))
+ else:
+ line = raw[i:lf]
+ i = lf + 1
+ lines.append(line.decode("utf-8"))
+
+ return lines
+
+ def flush(self) -> list[str]:
+ buf = self._buf
+ self._buf = b""
+ if buf == b"\r":
+ return [""]
+ if buf:
+ return [buf.decode("utf-8")]
+ return []
+
+
+class _SSEDecoder:
+ _event: str | None
+ _data: list[str]
+ _last_event_id: str | None
+ _retry: int | None
+
+ def __init__(self) -> None:
+ self._event = None
+ self._data = []
+ self._last_event_id = None
+ self._retry = None
+
+ def decode(self, line: str) -> _ServerSentEvent | None:
+ if not line:
+ if (
+ not self._event
+ and not self._data
+ and self._last_event_id is None
+ and self._retry is None
+ ):
+ return None
+
+ sse = _ServerSentEvent(
+ event=self._event or "message",
+ data="\n".join(self._data),
+ id=self._last_event_id or "",
+ retry=self._retry,
+ )
+
+ self._event = None
+ self._data = []
+ self._retry = None
+ return sse
+
+ if line.startswith(":"):
+ return None
+
+ fieldname, _, value = line.partition(":")
+ if value.startswith(" "):
+ value = value[1:]
+
+ if fieldname == "event":
+ self._event = value
+ elif fieldname == "data":
+ self._data.append(value)
+ elif fieldname == "id":
+ if "\0" not in value:
+ self._last_event_id = value
+ elif fieldname == "retry":
+ try:
+ self._retry = int(value)
+ except (TypeError, ValueError):
+ pass
+
+ return None
+
+
+class CloudflareSandboxClientOptions(BaseSandboxClientOptions):
+ """Options for ``CloudflareSandboxClient``."""
+
+ type: Literal["cloudflare"] = "cloudflare"
+ worker_url: str
+ api_key: str | None = None
+ exposed_ports: tuple[int, ...] = ()
+
+ def __init__(
+ self,
+ worker_url: str,
+ api_key: str | None = None,
+ exposed_ports: tuple[int, ...] = (),
+ *,
+ type: Literal["cloudflare"] = "cloudflare",
+ ) -> None:
+ super().__init__(
+ type=type,
+ worker_url=worker_url,
+ api_key=api_key,
+ exposed_ports=exposed_ports,
+ )
+
+
+class CloudflareSandboxSessionState(SandboxSessionState):
+ type: Literal["cloudflare"] = "cloudflare"
+ worker_url: str
+ sandbox_id: str
+
+
+@dataclass
+class _CloudflarePtyProcessEntry:
+ """Per-process state for a Cloudflare WebSocket PTY session."""
+
+ ws: aiohttp.ClientWebSocketResponse
+ tty: bool
+ last_used: float = field(default_factory=time.monotonic)
+ output_chunks: deque[bytes] = field(default_factory=deque)
+ output_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ output_notify: asyncio.Event = field(default_factory=asyncio.Event)
+ output_closed: asyncio.Event = field(default_factory=asyncio.Event)
+ pump_task: asyncio.Task[None] | None = None
+ exit_code: int | None = None
+
+
+class CloudflareSandboxSession(BaseSandboxSession):
+ """``BaseSandboxSession`` backed by a Cloudflare Worker over HTTP."""
+
+ state: CloudflareSandboxSessionState
+ _api_key: str | None
+ _http: aiohttp.ClientSession | None
+ _exec_timeout_s: float | None
+ _request_timeout_s: float | None
+ _pty_lock: asyncio.Lock
+ _pty_processes: dict[int, _CloudflarePtyProcessEntry]
+ _reserved_pty_process_ids: set[int]
+ # Tracks whether the worker was running when resume began so snapshot restore can
+ # detach any active ephemeral mounts before hydrating the workspace.
+ _restore_workspace_was_running: bool
+
+ def __init__(
+ self,
+ *,
+ state: CloudflareSandboxSessionState,
+ http: aiohttp.ClientSession | None = None,
+ api_key: str | None = None,
+ exec_timeout_s: float | None = None,
+ request_timeout_s: float | None = None,
+ ) -> None:
+ self.state = state
+ self._api_key = api_key
+ self._http = http
+ self._exec_timeout_s = exec_timeout_s
+ self._request_timeout_s = request_timeout_s
+ self._pty_lock = asyncio.Lock()
+ self._pty_processes = {}
+ self._reserved_pty_process_ids = set()
+ self._restore_workspace_was_running = False
+
+ @classmethod
+ def from_state(
+ cls,
+ state: CloudflareSandboxSessionState,
+ *,
+ http: aiohttp.ClientSession | None = None,
+ exec_timeout_s: float | None = None,
+ request_timeout_s: float | None = None,
+ ) -> CloudflareSandboxSession:
+ return cls(
+ state=state,
+ http=http,
+ exec_timeout_s=exec_timeout_s,
+ request_timeout_s=request_timeout_s,
+ )
+
+ def _session(self) -> aiohttp.ClientSession:
+ if self._http is None or self._http.closed:
+ headers: dict[str, str] = {}
+ if api_key := self._api_key or os.environ.get("CLOUDFLARE_SANDBOX_API_KEY"):
+ headers["Authorization"] = f"Bearer {api_key}"
+ self._http = aiohttp.ClientSession(headers=headers)
+ return self._http
+
+ def _url(self, path: str) -> str:
+ base = self.state.worker_url.rstrip("/")
+ return f"{base}/v1/sandbox/{self.state.sandbox_id}/{path.lstrip('/')}"
+
+ def _ws_pty_url(self, *, cols: int = 80, rows: int = 24) -> str:
+ base = self.state.worker_url.rstrip("/")
+ if base.startswith("https://"):
+ ws_base = f"wss://{base.removeprefix('https://')}"
+ elif base.startswith("http://"):
+ ws_base = f"ws://{base.removeprefix('http://')}"
+ else:
+ ws_base = base
+ return f"{ws_base}/v1/sandbox/{self.state.sandbox_id}/pty?cols={cols}&rows={rows}"
+
+ def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
+ return (RESOLVE_WORKSPACE_PATH_HELPER,)
+
+ def _current_runtime_helper_cache_key(self) -> object | None:
+ return self.state.sandbox_id
+
+ async def _normalize_path_for_io(self, path: Path | str) -> Path:
+ return await self._normalize_path_for_remote_io(path)
+
+ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
+ """Cloudflare sandboxes do not yet support exposed port resolution."""
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={
+ "backend": "cloudflare",
+ "detail": (
+ "The Cloudflare sandbox worker does not currently expose "
+ "a port-resolution endpoint. Exposed port support requires "
+ "a compatible worker deployment."
+ ),
+ },
+ )
+
+ async def mount_bucket(
+ self,
+ *,
+ bucket: str,
+ mount_path: Path | str,
+ options: dict[str, object],
+ ) -> None:
+ workspace_path = self.normalize_path(mount_path)
+ http = self._session()
+ url = self._url("mount")
+ payload = {
+ "bucket": bucket,
+ "mountPath": str(workspace_path),
+ "options": options,
+ }
+
+ try:
+ async with http.post(
+ url,
+ json=payload,
+ timeout=self._request_timeout(),
+ ) as resp:
+ if resp.status != 200:
+ body: dict[str, Any] = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise MountConfigError(
+ message="cloudflare bucket mount failed",
+ context={
+ "bucket": bucket,
+ "mount_path": str(workspace_path),
+ "http_status": resp.status,
+ "reason": body.get("error", f"HTTP {resp.status}"),
+ },
+ )
+ except MountConfigError:
+ raise
+ except aiohttp.ClientError as e:
+ raise MountConfigError(
+ message="cloudflare bucket mount failed",
+ context={
+ "bucket": bucket,
+ "mount_path": str(workspace_path),
+ "cause_type": type(e).__name__,
+ "reason": str(e),
+ },
+ ) from e
+
+ async def unmount_bucket(self, mount_path: Path | str) -> None:
+ workspace_path = self.normalize_path(mount_path)
+ http = self._session()
+ url = self._url("unmount")
+ payload = {"mountPath": str(workspace_path)}
+
+ try:
+ async with http.post(
+ url,
+ json=payload,
+ timeout=self._request_timeout(),
+ ) as resp:
+ if resp.status != 200:
+ body: dict[str, Any] = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise MountConfigError(
+ message="cloudflare bucket unmount failed",
+ context={
+ "mount_path": str(workspace_path),
+ "http_status": resp.status,
+ "reason": body.get("error", f"HTTP {resp.status}"),
+ },
+ )
+ except MountConfigError:
+ raise
+ except aiohttp.ClientError as e:
+ raise MountConfigError(
+ message="cloudflare bucket unmount failed",
+ context={
+ "mount_path": str(workspace_path),
+ "cause_type": type(e).__name__,
+ "reason": str(e),
+ },
+ ) from e
+
+ async def _close_http(self) -> None:
+ if self._http is not None and not self._http.closed:
+ await self._http.close()
+ self._http = None
+
+ def _request_timeout(self) -> aiohttp.ClientTimeout:
+ total = (
+ self._request_timeout_s
+ if self._request_timeout_s is not None
+ else _DEFAULT_REQUEST_TIMEOUT_S
+ )
+ return aiohttp.ClientTimeout(total=total)
+
+ def _decode_streamed_payload(self, body: bytes) -> bytes:
+ if not body.startswith(b"data: {"):
+ return body
+
+ try:
+ text = body.decode("utf-8")
+ except UnicodeDecodeError:
+ return body
+
+ line_decoder = _SSELineDecoder()
+ sse_decoder = _SSEDecoder()
+ is_binary = False
+ chunks: list[bytes] = []
+ saw_metadata = False
+ saw_chunk = False
+ saw_complete = False
+
+ def _handle_event_payload(data: str) -> None:
+ nonlocal is_binary, saw_complete, saw_chunk, saw_metadata
+ message = json.loads(data)
+ msg_type = message.get("type")
+ if msg_type == "metadata":
+ is_binary = bool(message.get("isBinary", False))
+ saw_metadata = True
+ return
+ if msg_type == "chunk":
+ if not saw_metadata:
+ raise ValueError("chunk event received before metadata")
+ chunk = message.get("data", "")
+ if is_binary:
+ chunks.append(base64.b64decode(chunk))
+ else:
+ chunks.append(str(chunk).encode("utf-8"))
+ saw_chunk = True
+ return
+ if msg_type == "complete":
+ if not saw_metadata:
+ raise ValueError("complete event received before metadata")
+ saw_complete = True
+ return
+
+ try:
+ for line in line_decoder.decode(text):
+ event = sse_decoder.decode(line)
+ if event is not None and event.event == "message" and event.data:
+ _handle_event_payload(event.data)
+
+ for line in line_decoder.flush():
+ event = sse_decoder.decode(line)
+ if event is not None and event.event == "message" and event.data:
+ _handle_event_payload(event.data)
+ except (ValueError, json.JSONDecodeError):
+ return body
+
+ if not saw_metadata or (not saw_chunk and not saw_complete):
+ return body
+ if not saw_complete:
+ raise ValueError("SSE payload ended without complete event")
+ return b"".join(chunks)
+
+ async def _prepare_backend_workspace(self) -> None:
+ try:
+ root = Path(self.state.manifest.root)
+ await self._exec_internal("mkdir", "-p", "--", str(root))
+ except Exception as e:
+ raise WorkspaceStartError(path=Path(self.state.manifest.root), cause=e) from e
+
+ async def _can_reuse_restorable_snapshot_workspace(self) -> bool:
+ if not self._workspace_state_preserved_on_start():
+ self._restore_workspace_was_running = False
+ return False
+
+ is_running = await self.running()
+ self._restore_workspace_was_running = is_running
+ if not self._can_reuse_preserved_workspace_on_resume():
+ return False
+ return await self._can_skip_snapshot_restore_on_resume(is_running=is_running)
+
+ async def _restore_snapshot_into_workspace_on_resume(self) -> None:
+ root = Path(self.state.manifest.root)
+ detached_mounts: list[tuple[Any, Path]] = []
+ if self._restore_workspace_was_running:
+ for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ raise WorkspaceStartError(path=root, cause=e) from e
+ detached_mounts.append((mount_entry, mount_path))
+
+ workspace_archive: io.IOBase | None = None
+ try:
+ await self._clear_workspace_root_on_resume()
+ workspace_archive = await self.state.snapshot.restore(dependencies=self.dependencies)
+ await self._hydrate_workspace_via_http(workspace_archive)
+ except Exception:
+ for mount_entry, mount_path in reversed(detached_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception:
+ pass
+ raise
+ finally:
+ if workspace_archive is not None:
+ try:
+ workspace_archive.close()
+ except Exception:
+ pass
+
+ async def _after_stop(self) -> None:
+ await self._close_http()
+
+ async def _shutdown_backend(self) -> None:
+ try:
+ http = self._session()
+ url = self.state.worker_url.rstrip("/") + f"/v1/sandbox/{self.state.sandbox_id}"
+ async with http.delete(url):
+ pass
+ except Exception:
+ logger.debug("Failed to delete Cloudflare sandbox on shutdown", exc_info=True)
+
+ async def _after_shutdown(self) -> None:
+ await self._close_http()
+
+ async def _exec_internal(
+ self,
+ *command: str | Path,
+ timeout: float | None = None,
+ ) -> ExecResult:
+ argv = [str(c) for c in command]
+ envs = await self.state.manifest.environment.resolve()
+ if envs:
+ argv = ["env", *[f"{key}={value}" for key, value in sorted(envs.items())], *argv]
+ effective_timeout = (
+ timeout
+ if timeout is not None
+ else (
+ self._exec_timeout_s
+ if self._exec_timeout_s is not None
+ else _DEFAULT_EXEC_TIMEOUT_S
+ )
+ )
+ payload: dict[str, Any] = {"argv": argv}
+ if effective_timeout is not None:
+ payload["timeout_ms"] = int(effective_timeout * 1000)
+
+ http = self._session()
+ url = self._url("exec")
+
+ try:
+ request_timeout = aiohttp.ClientTimeout(
+ total=effective_timeout + 5.0 if effective_timeout is not None else None
+ )
+ async with http.post(url, json=payload, timeout=request_timeout) as resp:
+ if resp.status != 200:
+ body: dict[str, Any] = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ msg = body.get("error", f"HTTP {resp.status}")
+ raise ExecTransportError(command=tuple(argv), cause=Exception(msg))
+
+ stdout_parts: list[bytes] = []
+ stderr_parts: list[bytes] = []
+ line_decoder = _SSELineDecoder()
+ sse_decoder = _SSEDecoder()
+
+ async for chunk in resp.content.iter_any():
+ text = chunk.decode("utf-8")
+ for line in line_decoder.decode(text):
+ event = sse_decoder.decode(line)
+ if event is None:
+ continue
+ if event.event == "stdout":
+ stdout_parts.append(base64.b64decode(event.data))
+ elif event.event == "stderr":
+ stderr_parts.append(base64.b64decode(event.data))
+ elif event.event == "exit":
+ exit_data = json.loads(event.data)
+ return ExecResult(
+ stdout=b"".join(stdout_parts),
+ stderr=b"".join(stderr_parts),
+ exit_code=int(exit_data["exit_code"]),
+ )
+ elif event.event == "error":
+ err_data = json.loads(event.data)
+ raise ExecTransportError(
+ command=tuple(argv),
+ cause=Exception(err_data.get("error", "unknown error")),
+ )
+
+ for line in line_decoder.flush():
+ event = sse_decoder.decode(line)
+ if event is None:
+ continue
+ if event.event == "stdout":
+ stdout_parts.append(base64.b64decode(event.data))
+ elif event.event == "stderr":
+ stderr_parts.append(base64.b64decode(event.data))
+ elif event.event == "exit":
+ exit_data = json.loads(event.data)
+ return ExecResult(
+ stdout=b"".join(stdout_parts),
+ stderr=b"".join(stderr_parts),
+ exit_code=int(exit_data["exit_code"]),
+ )
+ elif event.event == "error":
+ err_data = json.loads(event.data)
+ raise ExecTransportError(
+ command=tuple(argv),
+ cause=Exception(err_data.get("error", "unknown error")),
+ )
+
+ raise ExecTransportError(
+ command=tuple(argv),
+ cause=Exception("SSE stream ended without exit event"),
+ )
+
+ except asyncio.TimeoutError as e:
+ raise ExecTimeoutError(command=tuple(argv), timeout_s=effective_timeout, cause=e) from e
+ except (ExecTimeoutError, ExecTransportError):
+ raise
+ except aiohttp.ClientError as e:
+ raise ExecTransportError(command=tuple(argv), cause=e) from e
+ except Exception as e:
+ raise ExecTransportError(command=tuple(argv), cause=e) from e
+
+ def supports_pty(self) -> bool:
+ return True
+
+ async def _pump_ws_output(self, entry: _CloudflarePtyProcessEntry) -> None:
+ try:
+ while True:
+ msg = await entry.ws.receive()
+ if msg.type == aiohttp.WSMsgType.BINARY:
+ async with entry.output_lock:
+ entry.output_chunks.append(msg.data)
+ entry.output_notify.set()
+ continue
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ try:
+ payload = json.loads(msg.data)
+ except json.JSONDecodeError:
+ logger.debug("Ignoring non-JSON PTY text frame: %s", msg.data)
+ continue
+
+ msg_type = payload.get("type")
+ if msg_type == "ready":
+ continue
+ if msg_type == "exit":
+ code = payload.get("code")
+ entry.exit_code = code if isinstance(code, int) else None
+ entry.output_closed.set()
+ entry.output_notify.set()
+ break
+ if msg_type == "error":
+ logger.warning("Cloudflare PTY error frame: %s", payload.get("message"))
+ entry.output_closed.set()
+ entry.output_notify.set()
+ break
+ continue
+ if msg.type in (
+ aiohttp.WSMsgType.CLOSE,
+ aiohttp.WSMsgType.CLOSING,
+ aiohttp.WSMsgType.CLOSED,
+ aiohttp.WSMsgType.ERROR,
+ ):
+ entry.output_closed.set()
+ entry.output_notify.set()
+ break
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.debug("Cloudflare PTY pump ended with an exception", exc_info=True)
+ entry.output_closed.set()
+ entry.output_notify.set()
+
+ async def _collect_pty_output(
+ self,
+ *,
+ entry: _CloudflarePtyProcessEntry,
+ yield_time_ms: int,
+ max_output_tokens: int | None,
+ ) -> tuple[bytes, int | None]:
+ deadline = time.monotonic() + (yield_time_ms / 1000)
+ output = bytearray()
+
+ while True:
+ async with entry.output_lock:
+ while entry.output_chunks:
+ output.extend(entry.output_chunks.popleft())
+
+ if entry.output_closed.is_set():
+ async with entry.output_lock:
+ while entry.output_chunks:
+ output.extend(entry.output_chunks.popleft())
+ break
+
+ remaining_s = deadline - time.monotonic()
+ if remaining_s <= 0:
+ break
+
+ try:
+ await asyncio.wait_for(entry.output_notify.wait(), timeout=remaining_s)
+ except asyncio.TimeoutError:
+ break
+ entry.output_notify.clear()
+
+ text = output.decode("utf-8", errors="replace")
+ truncated_text, original_token_count = truncate_text_by_tokens(text, max_output_tokens)
+ return truncated_text.encode("utf-8", errors="replace"), original_token_count
+
+ async def _finalize_pty_update(
+ self,
+ *,
+ process_id: int,
+ entry: _CloudflarePtyProcessEntry,
+ output: bytes,
+ original_token_count: int | None,
+ ) -> PtyExecUpdate:
+ exit_code = entry.exit_code if entry.output_closed.is_set() else None
+ live_process_id: int | None = process_id
+ if entry.output_closed.is_set():
+ async with self._pty_lock:
+ removed = self._pty_processes.pop(process_id, None)
+ self._reserved_pty_process_ids.discard(process_id)
+ if removed is not None:
+ await self._terminate_pty_entry(removed)
+ live_process_id = None
+
+ return PtyExecUpdate(
+ process_id=live_process_id,
+ output=output,
+ exit_code=exit_code,
+ original_token_count=original_token_count,
+ )
+
+ async def _prune_pty_processes_if_needed(self) -> _CloudflarePtyProcessEntry | None:
+ if len(self._pty_processes) < PTY_PROCESSES_MAX:
+ return None
+
+ meta = [
+ (process_id, entry.last_used, entry.output_closed.is_set())
+ for process_id, entry in self._pty_processes.items()
+ ]
+ process_id_to_prune = process_id_to_prune_from_meta(meta)
+ if process_id_to_prune is None:
+ return None
+
+ self._reserved_pty_process_ids.discard(process_id_to_prune)
+ return self._pty_processes.pop(process_id_to_prune, None)
+
+ async def _terminate_pty_entry(self, entry: _CloudflarePtyProcessEntry) -> None:
+ with suppress(Exception):
+ await entry.ws.close()
+ if entry.pump_task is None:
+ return
+ entry.pump_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await entry.pump_task
+
+ async def _cleanup_unregistered_pty(
+ self,
+ entry: _CloudflarePtyProcessEntry | None,
+ ws: aiohttp.ClientWebSocketResponse | None,
+ registered: bool,
+ ) -> None:
+ """Best-effort cleanup of a PTY WebSocket or entry that was never registered."""
+ if entry is not None and not registered:
+ await self._terminate_pty_entry(entry)
+ elif ws is not None and not registered:
+ with suppress(Exception):
+ await ws.close()
+
+ 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:
+ _ = timeout
+ sanitized_command = self._prepare_exec_command(*command, shell=shell, user=user)
+ command_text = shlex.join(str(part) for part in sanitized_command)
+
+ ws: aiohttp.ClientWebSocketResponse | None = None
+ entry: _CloudflarePtyProcessEntry | None = None
+ registered = False
+ pruned_entry: _CloudflarePtyProcessEntry | None = None
+ process_id = 0
+ process_count = 0
+
+ try:
+ ws = await self._session().ws_connect(self._ws_pty_url())
+
+ ready_deadline = time.monotonic() + 30.0
+ while True:
+ remaining_s = ready_deadline - time.monotonic()
+ if remaining_s <= 0:
+ raise asyncio.TimeoutError()
+
+ msg = await asyncio.wait_for(ws.receive(), timeout=remaining_s)
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ try:
+ payload = json.loads(msg.data)
+ except json.JSONDecodeError:
+ continue
+ if payload.get("type") == "ready":
+ break
+ elif msg.type == aiohttp.WSMsgType.BINARY:
+ continue
+ elif msg.type in (
+ aiohttp.WSMsgType.CLOSE,
+ aiohttp.WSMsgType.CLOSING,
+ aiohttp.WSMsgType.CLOSED,
+ aiohttp.WSMsgType.ERROR,
+ ):
+ raise ExecTransportError(
+ command=tuple(str(part) for part in command),
+ cause=Exception("WebSocket closed before PTY ready"),
+ )
+
+ entry = _CloudflarePtyProcessEntry(ws=ws, tty=tty)
+ entry.pump_task = asyncio.create_task(self._pump_ws_output(entry))
+ await ws.send_bytes(f"{command_text}\n".encode())
+
+ async with self._pty_lock:
+ process_id = allocate_pty_process_id(self._reserved_pty_process_ids)
+ self._reserved_pty_process_ids.add(process_id)
+ pruned_entry = await self._prune_pty_processes_if_needed()
+ self._pty_processes[process_id] = entry
+ registered = True
+ process_count = len(self._pty_processes)
+ except asyncio.TimeoutError as e:
+ await self._cleanup_unregistered_pty(entry, ws, registered)
+ raise ExecTimeoutError(
+ command=tuple(str(part) for part in command),
+ timeout_s=30.0,
+ cause=e,
+ ) from e
+ except asyncio.CancelledError:
+ await self._cleanup_unregistered_pty(entry, ws, registered)
+ raise
+ except ExecTransportError:
+ await self._cleanup_unregistered_pty(entry, ws, registered)
+ raise
+ except Exception as e:
+ await self._cleanup_unregistered_pty(entry, ws, registered)
+ raise ExecTransportError(command=tuple(str(part) for part in command), cause=e) from e
+
+ if pruned_entry is not None:
+ await self._terminate_pty_entry(pruned_entry)
+
+ if process_count >= PTY_PROCESSES_WARNING:
+ logger.warning(
+ "PTY process count reached warning threshold: %s active sessions",
+ process_count,
+ )
+
+ yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms),
+ max_output_tokens=max_output_tokens,
+ )
+ return await self._finalize_pty_update(
+ process_id=process_id,
+ entry=entry,
+ output=output,
+ original_token_count=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:
+ async with self._pty_lock:
+ entry = self._resolve_pty_session_entry(
+ pty_processes=self._pty_processes,
+ session_id=session_id,
+ )
+
+ if chars:
+ if not entry.tty:
+ raise RuntimeError("stdin is not available for this process")
+ await entry.ws.send_bytes(chars.encode("utf-8"))
+ await asyncio.sleep(0.1)
+
+ yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=resolve_pty_write_yield_time_ms(
+ yield_time_ms=yield_time_ms,
+ input_empty=chars == "",
+ ),
+ max_output_tokens=max_output_tokens,
+ )
+ entry.last_used = time.monotonic()
+ return await self._finalize_pty_update(
+ process_id=session_id,
+ entry=entry,
+ output=output,
+ original_token_count=original_token_count,
+ )
+
+ async def pty_terminate_all(self) -> None:
+ async with self._pty_lock:
+ entries = list(self._pty_processes.values())
+ self._pty_processes.clear()
+ self._reserved_pty_process_ids.clear()
+
+ for entry in entries:
+ await self._terminate_pty_entry(entry)
+
+ async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
+ path = Path(path)
+ if user is not None:
+ await self._check_read_with_exec(path, user=user)
+
+ workspace_path = await self._normalize_path_for_io(path)
+ http = self._session()
+ url_path = quote(str(workspace_path).lstrip("/"), safe="/")
+ url = self._url(f"file/{url_path}")
+
+ try:
+ async with http.get(url, timeout=self._request_timeout()) as resp:
+ if resp.status == 404:
+ body: dict[str, Any] = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise WorkspaceReadNotFoundError(
+ path=workspace_path,
+ context={"message": body.get("error", "not found")},
+ )
+ if resp.status == 403:
+ body = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise WorkspaceArchiveReadError(
+ path=workspace_path,
+ context={
+ "reason": "path_escape",
+ "http_status": resp.status,
+ "message": body.get("error", "path escapes /workspace"),
+ },
+ )
+ if resp.status != 200:
+ body = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise WorkspaceArchiveReadError(
+ path=workspace_path,
+ context={
+ "reason": "http_error",
+ "http_status": resp.status,
+ "message": body.get("error", f"HTTP {resp.status}"),
+ },
+ )
+ return io.BytesIO(self._decode_streamed_payload(await resp.read()))
+ except (WorkspaceReadNotFoundError, WorkspaceArchiveReadError):
+ raise
+ except aiohttp.ClientError as e:
+ raise WorkspaceArchiveReadError(path=workspace_path, cause=e) from e
+ except Exception as e:
+ raise WorkspaceArchiveReadError(path=workspace_path, cause=e) from e
+
+ async def write(
+ self,
+ path: Path | str,
+ data: io.IOBase,
+ *,
+ user: str | User | None = None,
+ ) -> None:
+ path = Path(path)
+ if user is not None:
+ await self._check_write_with_exec(path, user=user)
+
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
+
+ payload_bytes = bytes(payload)
+ workspace_path = await self._normalize_path_for_io(path)
+
+ http = self._session()
+ url_path = quote(str(workspace_path).lstrip("/"), safe="/")
+ url = self._url(f"file/{url_path}")
+
+ try:
+ async with http.put(
+ url,
+ data=payload_bytes,
+ headers={"Content-Type": "application/octet-stream"},
+ timeout=self._request_timeout(),
+ ) as resp:
+ if resp.status == 403:
+ body: dict[str, Any] = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise WorkspaceArchiveWriteError(
+ path=workspace_path,
+ context={
+ "reason": "path_escape",
+ "http_status": resp.status,
+ "message": body.get("error", "path escapes /workspace"),
+ },
+ )
+ if resp.status != 200:
+ body = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise WorkspaceArchiveWriteError(
+ path=workspace_path,
+ context={
+ "reason": "http_error",
+ "http_status": resp.status,
+ "message": body.get("error", f"HTTP {resp.status}"),
+ },
+ )
+ except WorkspaceArchiveWriteError:
+ raise
+ except aiohttp.ClientError as e:
+ raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
+
+ async def running(self) -> bool:
+ http = self._session()
+ url = self._url("running")
+ try:
+ async with http.get(url, timeout=self._request_timeout()) as resp:
+ if resp.status != 200:
+ return False
+ data = await resp.json()
+ return bool(data.get("running", False))
+ except Exception:
+ return False
+
+ @retry_async(
+ retry_if=lambda exc, self: isinstance(exc, aiohttp.ClientError)
+ or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES)
+ or _is_transient_workspace_error(exc)
+ )
+ async def _persist_workspace_via_http(self) -> io.IOBase:
+ root = Path(self.state.manifest.root)
+ skip = self._persist_workspace_skip_relpaths()
+ excludes_param = ",".join(
+ rel.as_posix().removeprefix("./")
+ for rel in sorted(skip, key=lambda rel: rel.as_posix())
+ )
+ params: dict[str, str] = {}
+ if excludes_param:
+ params["excludes"] = excludes_param
+
+ http = self._session()
+ url = self._url("persist")
+ try:
+ async with http.post(url, params=params, timeout=self._request_timeout()) as resp:
+ if resp.status != 200:
+ body: dict[str, Any] = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "http_error",
+ "http_status": resp.status,
+ "message": body.get("error", f"HTTP {resp.status}"),
+ },
+ )
+ return io.BytesIO(self._decode_streamed_payload(await resp.read()))
+ except WorkspaceArchiveReadError:
+ raise
+ except aiohttp.ClientError as e:
+ raise WorkspaceArchiveReadError(path=root, cause=e) from e
+ except Exception as e:
+ raise WorkspaceArchiveReadError(path=root, cause=e) from e
+
+ @retry_async(
+ retry_if=lambda exc, self, data: isinstance(exc, aiohttp.ClientError)
+ or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES)
+ or _is_transient_workspace_error(exc)
+ )
+ async def _hydrate_workspace_via_http(self, data: io.IOBase) -> None:
+ root = Path(self.state.manifest.root)
+ raw = data.read()
+ if isinstance(raw, str):
+ raw = raw.encode("utf-8")
+ if not isinstance(raw, bytes | bytearray):
+ raise WorkspaceArchiveWriteError(path=root, context={"reason": "non_bytes_payload"})
+
+ try:
+ validate_tar_bytes(bytes(raw))
+ except UnsafeTarMemberError as e:
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "unsafe_or_invalid_tar",
+ "member": e.member,
+ "detail": str(e),
+ },
+ cause=e,
+ ) from e
+
+ http = self._session()
+ url = self._url("hydrate")
+ try:
+ async with http.post(
+ url,
+ data=bytes(raw),
+ headers={"Content-Type": "application/octet-stream"},
+ timeout=self._request_timeout(),
+ ) as resp:
+ if resp.status != 200:
+ body: dict[str, Any] = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "http_error",
+ "http_status": resp.status,
+ "message": body.get("error", f"HTTP {resp.status}"),
+ },
+ )
+ except WorkspaceArchiveWriteError:
+ raise
+ except aiohttp.ClientError as e:
+ raise WorkspaceArchiveWriteError(path=root, cause=e) from e
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=root, cause=e) from e
+
+ async def persist_workspace(self) -> io.IOBase:
+ root = Path(self.state.manifest.root)
+ unmounted_mounts: list[tuple[Any, Path]] = []
+ unmount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ unmount_error = WorkspaceArchiveReadError(path=root, cause=e)
+ break
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ snapshot_error: WorkspaceArchiveReadError | None = None
+ persisted: io.IOBase | None = None
+ if unmount_error is None:
+ try:
+ persisted = await self._persist_workspace_via_http()
+ except WorkspaceArchiveReadError as e:
+ snapshot_error = e
+
+ remount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ if remount_error is None:
+ remount_error = WorkspaceArchiveReadError(path=root, cause=e)
+
+ if remount_error is not None:
+ if snapshot_error is not None:
+ remount_error.context["snapshot_error_before_remount_corruption"] = {
+ "message": snapshot_error.message,
+ }
+ raise remount_error
+ if unmount_error is not None:
+ raise unmount_error
+ if snapshot_error is not None:
+ raise snapshot_error
+
+ assert persisted is not None
+ return persisted
+
+ async def hydrate_workspace(self, data: io.IOBase) -> None:
+ root = Path(self.state.manifest.root)
+ unmounted_mounts: list[tuple[Any, Path]] = []
+ unmount_error: WorkspaceArchiveWriteError | None = None
+ for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ unmount_error = WorkspaceArchiveWriteError(path=root, cause=e)
+ break
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ hydrate_error: WorkspaceArchiveWriteError | None = None
+ if unmount_error is None:
+ try:
+ await self._hydrate_workspace_via_http(data)
+ except WorkspaceArchiveWriteError as e:
+ hydrate_error = e
+
+ remount_error: WorkspaceArchiveWriteError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ if remount_error is None:
+ remount_error = WorkspaceArchiveWriteError(path=root, cause=e)
+
+ if remount_error is not None:
+ if hydrate_error is not None:
+ remount_error.context["hydrate_error_before_remount_corruption"] = {
+ "message": hydrate_error.message,
+ }
+ raise remount_error
+ if unmount_error is not None:
+ raise unmount_error
+ if hydrate_error is not None:
+ raise hydrate_error
+
+
+class CloudflareSandboxClient(BaseSandboxClient[CloudflareSandboxClientOptions]):
+ """Cloudflare Sandbox Service backed sandbox client."""
+
+ backend_id = "cloudflare"
+ _instrumentation: Instrumentation
+ _exec_timeout_s: float
+ _request_timeout_s: float
+
+ def __init__(
+ self,
+ *,
+ instrumentation: Instrumentation | None = None,
+ dependencies: Dependencies | None = None,
+ exec_timeout_s: float = _DEFAULT_EXEC_TIMEOUT_S,
+ request_timeout_s: float = _DEFAULT_REQUEST_TIMEOUT_S,
+ ) -> None:
+ super().__init__()
+ self._instrumentation = instrumentation or Instrumentation()
+ self._dependencies = dependencies
+ self._exec_timeout_s = exec_timeout_s
+ self._request_timeout_s = request_timeout_s
+
+ async def create(
+ self,
+ *,
+ snapshot: SnapshotSpec | SnapshotBase | None = None,
+ manifest: Manifest | None = None,
+ options: CloudflareSandboxClientOptions,
+ ) -> SandboxSession:
+ if not options.worker_url:
+ raise ConfigurationError(
+ message="CloudflareSandboxClientOptions.worker_url must not be empty",
+ error_code=ErrorCode.SANDBOX_CONFIG_INVALID,
+ op="start",
+ context={"backend": self.backend_id},
+ )
+
+ if manifest is None:
+ manifest = Manifest()
+ if manifest.root != "/workspace":
+ raise ConfigurationError(
+ message=(
+ "Cloudflare sandboxes only support manifest.root='/workspace' "
+ "because persistence and hydration are fixed to /workspace"
+ ),
+ error_code=ErrorCode.SANDBOX_CONFIG_INVALID,
+ op="start",
+ context={"backend": self.backend_id, "manifest_root": manifest.root},
+ )
+
+ # Resolve API key for auth.
+ api_key = options.api_key or os.environ.get("CLOUDFLARE_SANDBOX_API_KEY")
+
+ # Get a server-generated sandbox ID from the Cloudflare Sandbox Service.
+ sandbox_id = await self._request_sandbox_id(
+ options.worker_url, api_key, request_timeout_s=self._request_timeout_s
+ )
+
+ session_id = uuid.uuid4()
+ snapshot_instance = resolve_snapshot(snapshot, str(session_id))
+ state = CloudflareSandboxSessionState(
+ session_id=session_id,
+ manifest=manifest,
+ snapshot=snapshot_instance,
+ worker_url=options.worker_url.rstrip("/"),
+ sandbox_id=sandbox_id,
+ exposed_ports=options.exposed_ports,
+ )
+ inner = CloudflareSandboxSession(
+ state=state,
+ api_key=api_key,
+ exec_timeout_s=self._exec_timeout_s,
+ request_timeout_s=self._request_timeout_s,
+ )
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ async def delete(self, session: SandboxSession) -> SandboxSession:
+ inner = session._inner
+ if not isinstance(inner, CloudflareSandboxSession):
+ raise TypeError("CloudflareSandboxClient.delete expects a CloudflareSandboxSession")
+ await inner.shutdown()
+ return session
+
+ async def resume(self, state: SandboxSessionState) -> SandboxSession:
+ if not isinstance(state, CloudflareSandboxSessionState):
+ raise TypeError(
+ "CloudflareSandboxClient.resume expects a CloudflareSandboxSessionState"
+ )
+ inner = CloudflareSandboxSession.from_state(
+ state,
+ exec_timeout_s=self._exec_timeout_s,
+ request_timeout_s=self._request_timeout_s,
+ )
+ reconnected = await inner.running()
+ if not reconnected:
+ state.workspace_root_ready = False
+ inner._set_start_state_preserved(reconnected)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
+ return CloudflareSandboxSessionState.model_validate(payload)
+
+ async def _request_sandbox_id(
+ self,
+ worker_url: str,
+ api_key: str | None,
+ *,
+ request_timeout_s: float = _DEFAULT_REQUEST_TIMEOUT_S,
+ ) -> str:
+ """Request a sandbox ID from the Cloudflare Sandbox Service via ``POST /sandbox``."""
+ headers: dict[str, str] = {}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+ url = f"{worker_url.rstrip('/')}/v1/sandbox"
+ try:
+ async with aiohttp.ClientSession(headers=headers) as http:
+ async with http.post(
+ url, timeout=aiohttp.ClientTimeout(total=request_timeout_s)
+ ) as resp:
+ if resp.status != 200:
+ body: dict[str, Any] = {}
+ try:
+ body = await resp.json(content_type=None)
+ except Exception:
+ pass
+ raise ConfigurationError(
+ message=(
+ f"POST /sandbox failed: {body.get('error', f'HTTP {resp.status}')}"
+ ),
+ error_code=ErrorCode.SANDBOX_CONFIG_INVALID,
+ op="start",
+ context={"http_status": resp.status},
+ )
+ data = await resp.json()
+ sandbox_id = data.get("id")
+ if not isinstance(sandbox_id, str) or not sandbox_id:
+ raise ConfigurationError(
+ message="POST /sandbox returned invalid id",
+ error_code=ErrorCode.SANDBOX_CONFIG_INVALID,
+ op="start",
+ context={},
+ )
+ return sandbox_id
+ except ConfigurationError:
+ raise
+ except aiohttp.ClientError as e:
+ raise ConfigurationError(
+ message=f"POST /sandbox request failed: {e}",
+ error_code=ErrorCode.SANDBOX_CONFIG_INVALID,
+ op="start",
+ context={"cause_type": type(e).__name__},
+ ) from e
+
+
+__all__ = [
+ "CloudflareSandboxClient",
+ "CloudflareSandboxClientOptions",
+ "CloudflareSandboxSession",
+ "CloudflareSandboxSessionState",
+]
diff --git a/src/agents/extensions/sandbox/daytona/__init__.py b/src/agents/extensions/sandbox/daytona/__init__.py
new file mode 100644
index 00000000..e7f962e7
--- /dev/null
+++ b/src/agents/extensions/sandbox/daytona/__init__.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from ....sandbox.errors import (
+ ExposedPortUnavailableError,
+ InvalidManifestPathError,
+ WorkspaceArchiveReadError,
+)
+from .mounts import DaytonaCloudBucketMountStrategy
+from .sandbox import (
+ DEFAULT_DAYTONA_WORKSPACE_ROOT,
+ DaytonaSandboxClient,
+ DaytonaSandboxClientOptions,
+ DaytonaSandboxResources,
+ DaytonaSandboxSession,
+ DaytonaSandboxSessionState,
+ DaytonaSandboxTimeouts,
+)
+
+__all__ = [
+ "DEFAULT_DAYTONA_WORKSPACE_ROOT",
+ "DaytonaCloudBucketMountStrategy",
+ "DaytonaSandboxResources",
+ "DaytonaSandboxClient",
+ "DaytonaSandboxClientOptions",
+ "DaytonaSandboxSession",
+ "DaytonaSandboxSessionState",
+ "DaytonaSandboxTimeouts",
+ "ExposedPortUnavailableError",
+ "InvalidManifestPathError",
+ "WorkspaceArchiveReadError",
+]
diff --git a/src/agents/extensions/sandbox/daytona/mounts.py b/src/agents/extensions/sandbox/daytona/mounts.py
new file mode 100644
index 00000000..2d8fc259
--- /dev/null
+++ b/src/agents/extensions/sandbox/daytona/mounts.py
@@ -0,0 +1,247 @@
+"""Mount strategy for Daytona sandboxes.
+
+Provides ``DaytonaCloudBucketMountStrategy``, a wrapper around the generic
+:class:`InContainerMountStrategy` that ensures ``rclone`` is installed inside
+the sandbox before delegating to :class:`RcloneMountPattern`.
+
+Supports S3, R2, GCS, and Azure Blob mounts through a single code path.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Literal
+
+from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase
+from ....sandbox.entries.mounts.patterns import RcloneMountPattern
+from ....sandbox.errors import MountConfigError
+from ....sandbox.materialization import MaterializedFile
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+
+logger = logging.getLogger(__name__)
+
+_INSTALL_RETRIES = 3
+
+
+# ---------------------------------------------------------------------------
+# Tool provisioning helpers
+# ---------------------------------------------------------------------------
+
+
+async def _has_command(session: BaseSandboxSession, cmd: str) -> bool:
+ """Return True if *cmd* is on PATH or at a well-known location."""
+ check = await session.exec(
+ "sh",
+ "-lc",
+ f"command -v {cmd} >/dev/null 2>&1 || test -x /usr/local/bin/{cmd}",
+ shell=False,
+ )
+ return check.ok()
+
+
+async def _pkg_install(
+ session: BaseSandboxSession,
+ package: str,
+ *,
+ what: str,
+) -> None:
+ """Install *package* via apt-get or apk with retries.
+
+ Detects the available package manager (apt-get for Debian/Ubuntu, apk for
+ Alpine) and installs the package. Raises :class:`MountConfigError` with an
+ actionable message if neither is available or all install attempts fail.
+ """
+ if await _has_command(session, "apt-get"):
+ install_cmd = (
+ f"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {package}"
+ )
+ elif await _has_command(session, "apk"):
+ install_cmd = f"apk add --no-cache {package}"
+ else:
+ raise MountConfigError(
+ message=(
+ f"{what} is not installed and cannot be auto-installed "
+ f"(no supported package manager found). Preinstall {package} in your Daytona image."
+ ),
+ context={"package": package},
+ )
+
+ for attempt in range(_INSTALL_RETRIES):
+ result = await session.exec("sh", "-lc", install_cmd, shell=False, timeout=180, user="root")
+ if result.ok():
+ return
+ logger.warning(
+ "%s install attempt %d/%d failed (exit %d)",
+ package,
+ attempt + 1,
+ _INSTALL_RETRIES,
+ result.exit_code,
+ )
+
+ raise MountConfigError(
+ message=f"failed to install {package} after {_INSTALL_RETRIES} attempts",
+ context={"package": package, "exit_code": result.exit_code},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Preflight checks
+# ---------------------------------------------------------------------------
+
+
+async def _ensure_fuse_support(session: BaseSandboxSession) -> None:
+ """Verify the sandbox environment supports FUSE mounts.
+
+ Checks for /dev/fuse, the fuse kernel module, and fusermount userspace
+ tooling. If the kernel bits are present but fusermount is missing, attempts
+ to install ``fuse3`` via apt. Non-apt images must preinstall fuse3.
+ """
+ # Kernel-level requirements (cannot be installed).
+ dev_fuse = await session.exec("sh", "-lc", "test -c /dev/fuse", shell=False)
+ if not dev_fuse.ok():
+ raise MountConfigError(
+ message="/dev/fuse not available in this sandbox",
+ context={"missing": "/dev/fuse"},
+ )
+ kmod = await session.exec("sh", "-lc", "grep -qw fuse /proc/filesystems", shell=False)
+ if not kmod.ok():
+ raise MountConfigError(
+ message="FUSE kernel module not loaded in this sandbox",
+ context={"missing": "fuse in /proc/filesystems"},
+ )
+
+ # Userspace tooling — install if missing, re-verify after install.
+ if await _has_command(session, "fusermount3") or await _has_command(session, "fusermount"):
+ return
+
+ logger.info("fusermount not found; installing fuse3")
+ await _pkg_install(session, "fuse3", what="fusermount")
+
+ if not (
+ await _has_command(session, "fusermount3") or await _has_command(session, "fusermount")
+ ):
+ raise MountConfigError(
+ message="fuse3 was installed but fusermount is still not available",
+ context={"package": "fuse3"},
+ )
+
+
+async def _ensure_rclone(session: BaseSandboxSession) -> None:
+ """Install rclone inside the sandbox if it is not already available."""
+ if await _has_command(session, "rclone"):
+ return
+
+ logger.info("rclone not found in sandbox; installing via apt")
+ await _pkg_install(session, "rclone", what="rclone")
+
+ if not await _has_command(session, "rclone"):
+ raise MountConfigError(
+ message="rclone was installed but is still not available on PATH",
+ context={"package": "rclone"},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Session guard
+# ---------------------------------------------------------------------------
+
+
+def _assert_daytona_session(session: BaseSandboxSession) -> None:
+ if type(session).__name__ != "DaytonaSandboxSession":
+ raise MountConfigError(
+ message="daytona cloud bucket mounts require a DaytonaSandboxSession",
+ context={"session_type": type(session).__name__},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Strategy
+# ---------------------------------------------------------------------------
+
+
+class DaytonaCloudBucketMountStrategy(MountStrategyBase):
+ """Mount cloud buckets in Daytona sandboxes via rclone.
+
+ Wraps :class:`InContainerMountStrategy` with automatic ``rclone``
+ provisioning. Use with any provider mount (``S3Mount``, ``R2Mount``,
+ ``GCSMount``, ``AzureBlobMount``) and let the generic framework handle
+ config generation and mount execution.
+
+ Usage::
+
+ from agents.extensions.sandbox.daytona import DaytonaCloudBucketMountStrategy
+ from agents.sandbox.entries import S3Mount
+
+ mount = S3Mount(
+ bucket="my-bucket",
+ access_key_id="...",
+ secret_access_key="...",
+ mount_path=Path("/mnt/bucket"),
+ mount_strategy=DaytonaCloudBucketMountStrategy(),
+ )
+ """
+
+ type: Literal["daytona_cloud_bucket"] = "daytona_cloud_bucket"
+ pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse")
+
+ def _delegate(self) -> InContainerMountStrategy:
+ return InContainerMountStrategy(pattern=self.pattern)
+
+ def validate_mount(self, mount: Mount) -> None:
+ self._delegate().validate_mount(mount)
+
+ async def activate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> list[MaterializedFile]:
+ _assert_daytona_session(session)
+ if self.pattern.mode == "fuse":
+ await _ensure_fuse_support(session)
+ await _ensure_rclone(session)
+ return await self._delegate().activate(mount, session, dest, base_dir)
+
+ async def deactivate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> None:
+ _assert_daytona_session(session)
+ await self._delegate().deactivate(mount, session, dest, base_dir)
+
+ async def teardown_for_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_daytona_session(session)
+ await self._delegate().teardown_for_snapshot(mount, session, path)
+
+ async def restore_after_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_daytona_session(session)
+ if self.pattern.mode == "fuse":
+ await _ensure_fuse_support(session)
+ await _ensure_rclone(session)
+ await self._delegate().restore_after_snapshot(mount, session, path)
+
+ def build_docker_volume_driver_config(
+ self,
+ mount: Mount,
+ ) -> tuple[str, dict[str, str], bool] | None:
+ return None
+
+
+__all__ = [
+ "DaytonaCloudBucketMountStrategy",
+]
diff --git a/src/agents/extensions/sandbox/daytona/sandbox.py b/src/agents/extensions/sandbox/daytona/sandbox.py
new file mode 100644
index 00000000..98ce6d6f
--- /dev/null
+++ b/src/agents/extensions/sandbox/daytona/sandbox.py
@@ -0,0 +1,1204 @@
+"""
+Daytona sandbox (https://daytona.io) implementation.
+
+This module provides a Daytona-backed sandbox client/session implementation backed by
+`daytona.Sandbox` via the AsyncDaytona client.
+
+The `daytona` dependency is optional, so package-level exports should guard imports of this
+module. Within this module, Daytona SDK imports are lazy so users without the extra can still
+import the package.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import io
+import logging
+import math
+import shlex
+import time
+import uuid
+from collections import deque
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal, cast
+from urllib.parse import urlsplit
+
+from pydantic import BaseModel, Field
+
+from ....sandbox.entries import Mount
+from ....sandbox.errors import (
+ ExecTimeoutError,
+ ExecTransportError,
+ ExposedPortUnavailableError,
+ InvalidManifestPathError as InvalidManifestPathError,
+ WorkspaceArchiveReadError,
+ WorkspaceArchiveWriteError,
+ WorkspaceReadNotFoundError,
+ WorkspaceWriteTypeError,
+)
+from ....sandbox.manifest import Manifest
+from ....sandbox.session import SandboxSession, SandboxSessionState
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+from ....sandbox.session.dependencies import Dependencies
+from ....sandbox.session.manager import Instrumentation
+from ....sandbox.session.pty_types import (
+ PTY_PROCESSES_MAX,
+ PTY_PROCESSES_WARNING,
+ PtyExecUpdate,
+ allocate_pty_process_id,
+ clamp_pty_yield_time_ms,
+ process_id_to_prune_from_meta,
+ resolve_pty_write_yield_time_ms,
+ truncate_text_by_tokens,
+)
+from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
+from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
+from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
+from ....sandbox.util.retry import (
+ TRANSIENT_HTTP_STATUS_CODES,
+ exception_chain_contains_type,
+ exception_chain_has_status_code,
+ retry_async,
+)
+from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
+
+DEFAULT_DAYTONA_WORKSPACE_ROOT = "/home/daytona/workspace"
+logger = logging.getLogger(__name__)
+
+
+def _import_daytona_sdk() -> tuple[Any, Any, Any, Any]:
+ """Lazily import Daytona SDK classes, raising a clear error if missing."""
+ try:
+ from daytona import (
+ AsyncDaytona,
+ CreateSandboxFromImageParams,
+ CreateSandboxFromSnapshotParams,
+ DaytonaConfig,
+ )
+
+ return (
+ AsyncDaytona,
+ DaytonaConfig,
+ CreateSandboxFromSnapshotParams,
+ CreateSandboxFromImageParams,
+ )
+ except ImportError as e:
+ raise ImportError(
+ "DaytonaSandboxClient requires the optional `daytona` dependency.\n"
+ "Install the Daytona extra before using this sandbox backend."
+ ) from e
+
+
+def _import_sandbox_state() -> Any:
+ """Lazily import SandboxState enum from Daytona SDK, or None if unavailable."""
+ try:
+ from daytona import SandboxState
+
+ return SandboxState
+ except ImportError:
+ return None
+
+
+def _import_sdk_resources() -> Any:
+ """Lazily import Resources from Daytona SDK."""
+ try:
+ from daytona import Resources
+
+ return Resources
+ except ImportError as e:
+ raise ImportError(
+ "DaytonaSandboxClient requires the optional `daytona` dependency.\n"
+ "Install the Daytona extra before using this sandbox backend."
+ ) from e
+
+
+def _import_pty_size() -> Any:
+ """Lazily import PtySize from Daytona SDK."""
+ try:
+ from daytona.common.pty import PtySize
+
+ return PtySize
+ except ImportError as e:
+ raise ImportError(
+ "DaytonaSandboxClient requires the optional `daytona` dependency.\n"
+ "Install the Daytona extra before using this sandbox backend."
+ ) from e
+
+
+def _import_session_execute_request() -> Any:
+ """Lazily import SessionExecuteRequest from Daytona SDK."""
+ try:
+ from daytona import SessionExecuteRequest
+
+ return SessionExecuteRequest
+ except ImportError as e:
+ raise ImportError(
+ "DaytonaSandboxClient requires the optional `daytona` dependency.\n"
+ "Install the Daytona extra before using this sandbox backend."
+ ) from e
+
+
+def _import_daytona_exceptions() -> dict[str, type[BaseException]]:
+ """Best-effort import Daytona exception classes for fine-grained error mapping."""
+ try:
+ from daytona import (
+ DaytonaError,
+ DaytonaNotFoundError,
+ DaytonaRateLimitError,
+ DaytonaTimeoutError,
+ )
+ except Exception:
+ return {}
+ return {
+ "base": DaytonaError,
+ "timeout": DaytonaTimeoutError,
+ "not_found": DaytonaNotFoundError,
+ "rate_limit": DaytonaRateLimitError,
+ }
+
+
+def _retryable_persist_workspace_error_types() -> tuple[type[BaseException], ...]:
+ excs = _import_daytona_exceptions()
+ retryable: list[type[BaseException]] = [asyncio.TimeoutError]
+ timeout_exc = excs.get("timeout")
+ if timeout_exc is not None:
+ retryable.append(timeout_exc)
+ return tuple(retryable)
+
+
+class DaytonaSandboxResources(BaseModel):
+ """Resource configuration for a Daytona sandbox."""
+
+ model_config = {"frozen": True}
+
+ cpu: int | None = None
+ memory: int | None = None
+ disk: int | None = None
+
+
+class DaytonaSandboxTimeouts(BaseModel):
+ """Timeout configuration for Daytona sandbox operations."""
+
+ exec_timeout_unbounded_s: int = Field(default=24 * 60 * 60, ge=1)
+ keepalive_s: int = Field(default=10, ge=1)
+ cleanup_s: int = Field(default=30, ge=1)
+ fast_op_s: int = Field(default=30, ge=1)
+ file_upload_s: int = Field(default=1800, ge=1)
+ file_download_s: int = Field(default=1800, ge=1)
+ workspace_tar_s: int = Field(default=300, ge=1)
+
+
+class DaytonaSandboxClientOptions(BaseSandboxClientOptions):
+ """Client options for the Daytona sandbox."""
+
+ type: Literal["daytona"] = "daytona"
+ sandbox_snapshot_name: str | None = None
+ image: str | None = None
+ resources: DaytonaSandboxResources | None = None
+ env_vars: dict[str, str] | None = None
+ pause_on_exit: bool = False
+ create_timeout: int = 60
+ start_timeout: int = 60
+ name: str | None = None
+ auto_stop_interval: int = 0
+ timeouts: DaytonaSandboxTimeouts | dict[str, object] | None = None
+ exposed_ports: tuple[int, ...] = ()
+ # This TTL applies to new connection setup only: Daytona checks signed preview URL expiry during
+ # the initial HTTP request / websocket upgrade handshake. In live testing, an already-open
+ # websocket stayed connected after the URL expired, but any reconnect or new handshake needed a
+ # freshly resolved URL.
+ exposed_port_url_ttl_s: int = 3600
+
+ def __init__(
+ self,
+ sandbox_snapshot_name: str | None = None,
+ image: str | None = None,
+ resources: DaytonaSandboxResources | None = None,
+ env_vars: dict[str, str] | None = None,
+ pause_on_exit: bool = False,
+ create_timeout: int = 60,
+ start_timeout: int = 60,
+ name: str | None = None,
+ auto_stop_interval: int = 0,
+ timeouts: DaytonaSandboxTimeouts | dict[str, object] | None = None,
+ exposed_ports: tuple[int, ...] = (),
+ exposed_port_url_ttl_s: int = 3600,
+ *,
+ type: Literal["daytona"] = "daytona",
+ ) -> None:
+ super().__init__(
+ type=type,
+ sandbox_snapshot_name=sandbox_snapshot_name,
+ image=image,
+ resources=resources,
+ env_vars=env_vars,
+ pause_on_exit=pause_on_exit,
+ create_timeout=create_timeout,
+ start_timeout=start_timeout,
+ name=name,
+ auto_stop_interval=auto_stop_interval,
+ timeouts=timeouts,
+ exposed_ports=exposed_ports,
+ exposed_port_url_ttl_s=exposed_port_url_ttl_s,
+ )
+
+
+class DaytonaSandboxSessionState(SandboxSessionState):
+ """Serializable state for a Daytona-backed session."""
+
+ type: Literal["daytona"] = "daytona"
+ sandbox_id: str
+ sandbox_snapshot_name: str | None = None
+ image: str | None = None
+ base_env_vars: dict[str, str] = Field(default_factory=dict)
+ pause_on_exit: bool = False
+ create_timeout: int = 60
+ start_timeout: int = 60
+ name: str | None = None
+ resources: DaytonaSandboxResources | None = None
+ auto_stop_interval: int = 0
+ timeouts: DaytonaSandboxTimeouts = Field(default_factory=DaytonaSandboxTimeouts)
+ exposed_port_url_ttl_s: int = 3600
+
+
+@dataclass
+class _DaytonaPtySessionEntry:
+ daytona_session_id: str
+ pty_handle: Any
+ tty: bool = True
+ cmd_id: str | None = None
+ output_chunks: deque[bytes] = field(default_factory=deque)
+ output_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ output_notify: asyncio.Event = field(default_factory=asyncio.Event)
+ last_used: float = field(default_factory=time.monotonic)
+ done: bool = False
+ exit_code: int | None = None
+
+
+class DaytonaSandboxSession(BaseSandboxSession):
+ """Daytona-backed sandbox session implementation."""
+
+ state: DaytonaSandboxSessionState
+ _sandbox: Any
+ _pty_lock: asyncio.Lock
+ _pty_sessions: dict[int, _DaytonaPtySessionEntry]
+ _reserved_pty_process_ids: set[int]
+
+ def __init__(self, *, state: DaytonaSandboxSessionState, sandbox: Any) -> None:
+ self.state = state
+ self._sandbox = sandbox
+ self._pty_lock = asyncio.Lock()
+ self._pty_sessions = {}
+ self._reserved_pty_process_ids = set()
+
+ @classmethod
+ def from_state(
+ cls,
+ state: DaytonaSandboxSessionState,
+ *,
+ sandbox: Any,
+ ) -> DaytonaSandboxSession:
+ return cls(state=state, sandbox=sandbox)
+
+ @property
+ def sandbox_id(self) -> str:
+ return self.state.sandbox_id
+
+ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
+ try:
+ preview = await self._sandbox.create_signed_preview_url(
+ port,
+ expires_in_seconds=self.state.exposed_port_url_ttl_s,
+ )
+ except Exception as e:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "daytona", "detail": "create_signed_preview_url_failed"},
+ cause=e,
+ ) from e
+
+ url = getattr(preview, "url", None)
+ if not isinstance(url, str) or not url:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "daytona", "detail": "invalid_preview_url", "url": url},
+ )
+
+ try:
+ split = urlsplit(url)
+ host = split.hostname
+ if host is None:
+ raise ValueError("missing hostname")
+ port_value = split.port or (443 if split.scheme == "https" else 80)
+ return ExposedPortEndpoint(host=host, port=port_value, tls=split.scheme == "https")
+ except Exception as e:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "daytona", "detail": "invalid_preview_url", "url": url},
+ cause=e,
+ ) from e
+
+ async def _shutdown_backend(self) -> None:
+ try:
+ if self.state.pause_on_exit:
+ await self._sandbox.stop()
+ else:
+ await self._sandbox.delete()
+ except Exception:
+ pass
+
+ async def mkdir(
+ self,
+ path: Path | str,
+ *,
+ parents: bool = False,
+ user: str | User | None = None,
+ ) -> None:
+ if user is not None:
+ path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
+ else:
+ path = self.normalize_path(path)
+ if path == Path("/"):
+ return
+ try:
+ await self._sandbox.fs.create_folder(str(path), "755")
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(
+ path=path,
+ context={"reason": "mkdir_failed"},
+ cause=e,
+ ) from e
+
+ async def _resolved_envs(self) -> dict[str, str]:
+ manifest_envs = await self.state.manifest.environment.resolve()
+ return {**self.state.base_env_vars, **manifest_envs}
+
+ def _coerce_exec_timeout(self, timeout_s: float | None) -> float:
+ if timeout_s is None:
+ return float(self.state.timeouts.exec_timeout_unbounded_s)
+ if timeout_s <= 0:
+ return 0.001
+ return float(timeout_s)
+
+ async def _exec_internal(
+ self,
+ *command: str | Path,
+ timeout: float | None = None,
+ ) -> ExecResult:
+ cmd_str = shlex.join(str(c) for c in command)
+ envs = await self._resolved_envs()
+ cwd = self.state.manifest.root
+ env_args = (
+ " ".join(shlex.quote(f"{key}={value}") for key, value in envs.items()) if envs else ""
+ )
+ env_wrapper = f"env -- {env_args} " if env_args else ""
+ session_cmd = f"cd {shlex.quote(cwd)} && {env_wrapper}{cmd_str}"
+ daytona_session_id = f"sandbox-{uuid.uuid4().hex[:12]}"
+
+ caller_timeout = self._coerce_exec_timeout(timeout)
+ deadline = time.monotonic() + caller_timeout
+ SessionExecuteRequest = _import_session_execute_request()
+ daytona_exc = _import_daytona_exceptions()
+ timeout_exc = daytona_exc.get("timeout")
+
+ def _remaining_timeout() -> float:
+ return max(0.0, deadline - time.monotonic())
+
+ try:
+ await asyncio.wait_for(
+ self._sandbox.process.create_session(daytona_session_id),
+ timeout=_remaining_timeout(),
+ )
+ command_timeout = _remaining_timeout()
+ sdk_timeout = max(1, math.ceil(command_timeout + 1.0))
+ result = await asyncio.wait_for(
+ self._sandbox.process.execute_session_command(
+ daytona_session_id,
+ SessionExecuteRequest(command=session_cmd, run_async=False),
+ timeout=sdk_timeout,
+ ),
+ timeout=caller_timeout,
+ )
+ exit_code = int(result.exit_code or 0)
+ stdout = getattr(result, "stdout", None)
+ stderr = getattr(result, "stderr", None)
+ if stdout is None and stderr is None:
+ output = getattr(result, "output", "") or ""
+ if exit_code == 0:
+ stdout = output
+ stderr = ""
+ else:
+ stdout = ""
+ stderr = output
+ return ExecResult(
+ stdout=(stdout or "").encode("utf-8", errors="replace"),
+ stderr=(stderr or "").encode("utf-8", errors="replace"),
+ exit_code=exit_code,
+ )
+ except asyncio.TimeoutError as e:
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ except Exception as e:
+ if timeout_exc is not None and isinstance(e, timeout_exc):
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ raise ExecTransportError(command=command, cause=e) from e
+ finally:
+ try:
+ await asyncio.wait_for(
+ self._sandbox.process.delete_session(daytona_session_id),
+ timeout=self.state.timeouts.cleanup_s,
+ )
+ except Exception:
+ pass
+
+ def supports_pty(self) -> bool:
+ return True
+
+ 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:
+ PtySize = _import_pty_size()
+ sanitized = self._prepare_exec_command(*command, shell=shell, user=user)
+ cmd_str = shlex.join(str(part) for part in sanitized)
+ envs = await self._resolved_envs()
+ cwd = self.state.manifest.root
+ exec_timeout = self._coerce_exec_timeout(timeout)
+ daytona_exc = _import_daytona_exceptions()
+ timeout_exc = daytona_exc.get("timeout")
+
+ daytona_session_id = f"sandbox-{uuid.uuid4().hex[:12]}"
+ entry = _DaytonaPtySessionEntry(
+ daytona_session_id=daytona_session_id,
+ pty_handle=None,
+ tty=tty,
+ )
+
+ async def _on_data(chunk: bytes | str) -> None:
+ raw = (
+ chunk.encode("utf-8", errors="replace") if isinstance(chunk, str) else bytes(chunk)
+ )
+ async with entry.output_lock:
+ entry.output_chunks.append(raw)
+ entry.output_notify.set()
+
+ pruned: _DaytonaPtySessionEntry | None = None
+ registered = False
+ try:
+ if tty:
+ pty_handle = await asyncio.wait_for(
+ self._sandbox.process.create_pty_session(
+ id=daytona_session_id,
+ on_data=_on_data,
+ cwd=cwd,
+ envs=envs or None,
+ pty_size=PtySize(cols=80, rows=24),
+ ),
+ timeout=exec_timeout,
+ )
+ entry.pty_handle = pty_handle
+ asyncio.create_task(self._run_pty_waiter(entry))
+ await asyncio.wait_for(pty_handle.wait_for_connection(), timeout=exec_timeout)
+ await asyncio.wait_for(
+ pty_handle.send_input(cmd_str + "\n"),
+ timeout=self.state.timeouts.fast_op_s,
+ )
+ else:
+ SessionExecuteRequest = _import_session_execute_request()
+ env_args = (
+ " ".join(shlex.quote(f"{key}={value}") for key, value in envs.items())
+ if envs
+ else ""
+ )
+ env_wrapper = f"env -- {env_args} " if env_args else ""
+ session_cmd = f"cd {shlex.quote(cwd)} && {env_wrapper}{cmd_str}"
+ await asyncio.wait_for(
+ self._sandbox.process.create_session(daytona_session_id),
+ timeout=exec_timeout,
+ )
+ resp = await asyncio.wait_for(
+ self._sandbox.process.execute_session_command(
+ daytona_session_id,
+ SessionExecuteRequest(command=session_cmd, run_async=True),
+ ),
+ timeout=exec_timeout,
+ )
+ entry.cmd_id = resp.cmd_id
+ asyncio.create_task(
+ self._run_session_reader(
+ entry,
+ daytona_session_id,
+ resp.cmd_id,
+ _on_data,
+ )
+ )
+
+ async with self._pty_lock:
+ process_id = allocate_pty_process_id(self._reserved_pty_process_ids)
+ self._reserved_pty_process_ids.add(process_id)
+ pruned = self._prune_pty_sessions_if_needed()
+ self._pty_sessions[process_id] = entry
+ process_count = len(self._pty_sessions)
+ registered = True
+ except asyncio.TimeoutError as e:
+ if not registered:
+ cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
+ try:
+ await asyncio.shield(cleanup_task)
+ except BaseException:
+ await asyncio.shield(cleanup_task)
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ except Exception as e:
+ if not registered:
+ cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
+ try:
+ await asyncio.shield(cleanup_task)
+ except BaseException:
+ await asyncio.shield(cleanup_task)
+ if timeout_exc is not None and isinstance(e, timeout_exc):
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ raise ExecTransportError(command=command, cause=e) from e
+ except BaseException:
+ if not registered:
+ cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
+ try:
+ await asyncio.shield(cleanup_task)
+ except BaseException:
+ await asyncio.shield(cleanup_task)
+ raise
+
+ if pruned is not None:
+ await self._terminate_pty_entry(pruned)
+
+ if process_count >= PTY_PROCESSES_WARNING:
+ logger.warning(
+ "PTY process count reached warning threshold: %s active sessions",
+ process_count,
+ )
+
+ yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms),
+ max_output_tokens=max_output_tokens,
+ )
+ return await self._finalize_pty_update(
+ process_id=process_id,
+ entry=entry,
+ output=output,
+ original_token_count=original_token_count,
+ )
+
+ async def _run_pty_waiter(self, entry: _DaytonaPtySessionEntry) -> None:
+ try:
+ await entry.pty_handle.wait()
+ ec = getattr(entry.pty_handle, "exit_code", None)
+ if ec is not None:
+ entry.exit_code = int(ec)
+ except Exception:
+ pass
+ finally:
+ entry.done = True
+ entry.output_notify.set()
+
+ async def _run_session_reader(
+ self,
+ entry: _DaytonaPtySessionEntry,
+ session_id: str,
+ cmd_id: str,
+ on_data: Any,
+ ) -> None:
+ logs_failed = False
+ try:
+ await self._sandbox.process.get_session_command_logs_async(
+ session_id,
+ cmd_id,
+ on_data,
+ on_data,
+ )
+ except Exception:
+ logs_failed = True
+ finally:
+ try:
+ cmd = await self._sandbox.process.get_session_command(session_id, cmd_id)
+ if cmd.exit_code is not None:
+ entry.exit_code = int(cmd.exit_code)
+ entry.done = True
+ except Exception:
+ pass
+ if not logs_failed:
+ entry.done = True
+ entry.output_notify.set()
+
+ async def pty_write_stdin(
+ self,
+ *,
+ session_id: int,
+ chars: str,
+ yield_time_s: float | None = None,
+ max_output_tokens: int | None = None,
+ ) -> PtyExecUpdate:
+ async with self._pty_lock:
+ entry = self._resolve_pty_session_entry(
+ pty_processes=self._pty_sessions,
+ session_id=session_id,
+ )
+
+ if chars:
+ if not entry.tty:
+ raise RuntimeError("stdin is not available for this process")
+ await asyncio.wait_for(
+ entry.pty_handle.send_input(chars),
+ timeout=self.state.timeouts.fast_op_s,
+ )
+ await asyncio.sleep(0.1)
+
+ yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=resolve_pty_write_yield_time_ms(
+ yield_time_ms=yield_time_ms, input_empty=chars == ""
+ ),
+ max_output_tokens=max_output_tokens,
+ )
+ entry.last_used = time.monotonic()
+ return await self._finalize_pty_update(
+ process_id=session_id,
+ entry=entry,
+ output=output,
+ original_token_count=original_token_count,
+ )
+
+ async def _finalize_pty_update(
+ self,
+ *,
+ process_id: int,
+ entry: _DaytonaPtySessionEntry,
+ output: bytes,
+ original_token_count: int | None,
+ ) -> PtyExecUpdate:
+ exit_code = entry.exit_code if entry.done else None
+ live_process_id: int | None = process_id
+
+ if entry.done:
+ async with self._pty_lock:
+ removed = self._pty_sessions.pop(process_id, None)
+ self._reserved_pty_process_ids.discard(process_id)
+ if removed is not None:
+ await self._terminate_pty_entry(removed)
+ live_process_id = None
+
+ return PtyExecUpdate(
+ process_id=live_process_id,
+ output=output,
+ exit_code=exit_code,
+ original_token_count=original_token_count,
+ )
+
+ async def pty_terminate_all(self) -> None:
+ async with self._pty_lock:
+ entries = list(self._pty_sessions.values())
+ self._pty_sessions.clear()
+ self._reserved_pty_process_ids.clear()
+ for entry in entries:
+ await self._terminate_pty_entry(entry)
+
+ async def _collect_pty_output(
+ self,
+ *,
+ entry: _DaytonaPtySessionEntry,
+ yield_time_ms: int,
+ max_output_tokens: int | None,
+ ) -> tuple[bytes, int | None]:
+ deadline = time.monotonic() + (yield_time_ms / 1000)
+ output = bytearray()
+
+ while True:
+ async with entry.output_lock:
+ while entry.output_chunks:
+ output.extend(entry.output_chunks.popleft())
+
+ if time.monotonic() >= deadline:
+ break
+
+ if entry.done:
+ async with entry.output_lock:
+ while entry.output_chunks:
+ output.extend(entry.output_chunks.popleft())
+ break
+
+ remaining_s = deadline - time.monotonic()
+ if remaining_s <= 0:
+ break
+
+ try:
+ await asyncio.wait_for(entry.output_notify.wait(), timeout=remaining_s)
+ except asyncio.TimeoutError:
+ break
+ entry.output_notify.clear()
+
+ text = output.decode("utf-8", errors="replace")
+ truncated, original_token_count = truncate_text_by_tokens(text, max_output_tokens)
+ return truncated.encode("utf-8", errors="replace"), original_token_count
+
+ def _prune_pty_sessions_if_needed(self) -> _DaytonaPtySessionEntry | None:
+ if len(self._pty_sessions) < PTY_PROCESSES_MAX:
+ return None
+ meta: list[tuple[int, float, bool]] = [
+ (pid, entry.last_used, entry.done) for pid, entry in self._pty_sessions.items()
+ ]
+ pid = process_id_to_prune_from_meta(meta)
+ if pid is None:
+ return None
+ self._reserved_pty_process_ids.discard(pid)
+ return self._pty_sessions.pop(pid, None)
+
+ async def _terminate_pty_entry(self, entry: _DaytonaPtySessionEntry) -> None:
+ try:
+ if entry.tty:
+ await self._sandbox.process.kill_pty_session(entry.daytona_session_id)
+ else:
+ await self._sandbox.process.delete_session(entry.daytona_session_id)
+ except Exception:
+ pass
+
+ async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
+ path = Path(path)
+ if user is not None:
+ await self._check_read_with_exec(path, user=user)
+
+ workspace_path = self.normalize_path(path)
+ daytona_exc = _import_daytona_exceptions()
+ not_found_exc = daytona_exc.get("not_found")
+
+ try:
+ data: bytes = await self._sandbox.fs.download_file(
+ str(workspace_path),
+ self.state.timeouts.file_download_s,
+ )
+ return io.BytesIO(data)
+ except Exception as e:
+ if not_found_exc is not None and isinstance(e, not_found_exc):
+ raise WorkspaceReadNotFoundError(path=path, cause=e) from e
+ raise WorkspaceArchiveReadError(path=path, cause=e) from e
+
+ async def write(
+ self,
+ path: Path | str,
+ data: io.IOBase,
+ *,
+ user: str | User | None = None,
+ ) -> None:
+ path = Path(path)
+ if user is not None:
+ await self._check_write_with_exec(path, user=user)
+
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
+
+ workspace_path = self.normalize_path(path)
+ try:
+ await self._sandbox.fs.upload_file(
+ bytes(payload),
+ str(workspace_path),
+ timeout=self.state.timeouts.file_upload_s,
+ )
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
+
+ async def running(self) -> bool:
+ try:
+ await asyncio.wait_for(
+ self._sandbox.refresh_data(),
+ timeout=self.state.timeouts.keepalive_s,
+ )
+ SandboxState = _import_sandbox_state()
+ if SandboxState is None:
+ return False
+ return bool(getattr(self._sandbox, "state", None) == SandboxState.STARTED)
+ except Exception:
+ return False
+
+ def _tar_exclude_args(self) -> list[str]:
+ excludes: list[str] = []
+ for rel in sorted(self._persist_workspace_skip_relpaths(), key=lambda p: p.as_posix()):
+ rel_posix = rel.as_posix().lstrip("/")
+ if not rel_posix or rel_posix in {".", "/"}:
+ continue
+ excludes.append(f"--exclude={shlex.quote(rel_posix)}")
+ excludes.append(f"--exclude={shlex.quote(f'./{rel_posix}')}")
+ return excludes
+
+ @retry_async(
+ retry_if=lambda exc, self, tar_cmd, tar_path: (
+ exception_chain_contains_type(exc, _retryable_persist_workspace_error_types())
+ or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES)
+ )
+ )
+ async def _run_persist_workspace_command(self, tar_cmd: str, tar_path: str) -> bytes:
+ root = self.state.manifest.root
+ try:
+ envs = await self._resolved_envs()
+ result = await self._sandbox.process.exec(
+ tar_cmd,
+ env=envs or None,
+ timeout=self.state.timeouts.workspace_tar_s,
+ )
+ if result.exit_code != 0:
+ raise WorkspaceArchiveReadError(
+ path=Path(root),
+ context={"reason": "tar_failed", "output": result.result or ""},
+ )
+ return cast(
+ bytes,
+ await self._sandbox.fs.download_file(
+ tar_path,
+ self.state.timeouts.file_download_s,
+ ),
+ )
+ except WorkspaceArchiveReadError:
+ raise
+ except Exception as e:
+ raise WorkspaceArchiveReadError(path=Path(root), cause=e) from e
+
+ async def persist_workspace(self) -> io.IOBase:
+ def _error_context_summary(error: WorkspaceArchiveReadError) -> dict[str, str]:
+ summary = {"message": error.message}
+ if error.cause is not None:
+ summary["cause_type"] = type(error.cause).__name__
+ summary["cause"] = str(error.cause)
+ return summary
+
+ root = Path(self.state.manifest.root)
+ tar_path = f"/tmp/sandbox-persist-{self.state.session_id.hex}.tar"
+ excludes = " ".join(self._tar_exclude_args())
+ tar_cmd = (
+ f"tar {excludes} -C {shlex.quote(str(root))} -cf {shlex.quote(tar_path)} ."
+ ).strip()
+
+ unmounted_mounts: list[tuple[Mount, Path]] = []
+ unmount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ unmount_error = WorkspaceArchiveReadError(path=root, cause=e)
+ break
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ snapshot_error: WorkspaceArchiveReadError | None = None
+ raw: bytes | None = None
+ if unmount_error is None:
+ try:
+ raw = await self._run_persist_workspace_command(tar_cmd, tar_path)
+ except WorkspaceArchiveReadError as e:
+ snapshot_error = e
+ finally:
+ try:
+ await self._sandbox.process.exec(
+ f"rm -f -- {shlex.quote(tar_path)}",
+ timeout=self.state.timeouts.cleanup_s,
+ )
+ except Exception:
+ pass
+
+ remount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ current_error = WorkspaceArchiveReadError(path=root, cause=e)
+ if remount_error is None:
+ remount_error = current_error
+ if unmount_error is not None:
+ remount_error.context["earlier_unmount_error"] = _error_context_summary(
+ unmount_error
+ )
+ else:
+ additional_remount_errors = remount_error.context.setdefault(
+ "additional_remount_errors",
+ [],
+ )
+ assert isinstance(additional_remount_errors, list)
+ additional_remount_errors.append(_error_context_summary(current_error))
+
+ if remount_error is not None:
+ if snapshot_error is not None:
+ remount_error.context["snapshot_error_before_remount_corruption"] = (
+ _error_context_summary(snapshot_error)
+ )
+ raise remount_error
+ if unmount_error is not None:
+ raise unmount_error
+ if snapshot_error is not None:
+ raise snapshot_error
+
+ assert raw is not None
+ return io.BytesIO(raw)
+
+ async def hydrate_workspace(self, data: io.IOBase) -> None:
+ root = self.state.manifest.root
+ tar_path = f"/tmp/sandbox-hydrate-{self.state.session_id.hex}.tar"
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=Path(tar_path), actual_type=type(payload).__name__)
+
+ try:
+ validate_tar_bytes(bytes(payload))
+ except UnsafeTarMemberError as e:
+ raise WorkspaceArchiveWriteError(
+ path=Path(root),
+ context={
+ "reason": "unsafe_or_invalid_tar",
+ "member": e.member,
+ "detail": str(e),
+ },
+ cause=e,
+ ) from e
+
+ try:
+ await self.mkdir(root, parents=True)
+ envs = await self._resolved_envs()
+ await self._sandbox.fs.upload_file(
+ bytes(payload),
+ tar_path,
+ timeout=self.state.timeouts.file_upload_s,
+ )
+ result = await self._sandbox.process.exec(
+ f"tar -C {shlex.quote(root)} -xf {shlex.quote(tar_path)}",
+ env=envs or None,
+ timeout=self.state.timeouts.workspace_tar_s,
+ )
+ if result.exit_code != 0:
+ raise WorkspaceArchiveWriteError(
+ path=Path(root),
+ context={"reason": "tar_extract_failed", "output": result.result or ""},
+ )
+ except WorkspaceArchiveWriteError:
+ raise
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=Path(root), cause=e) from e
+ finally:
+ try:
+ envs = await self._resolved_envs()
+ await self._sandbox.process.exec(
+ f"rm -f -- {shlex.quote(tar_path)}",
+ env=envs or None,
+ timeout=self.state.timeouts.cleanup_s,
+ )
+ except Exception:
+ pass
+
+
+class DaytonaSandboxClient(BaseSandboxClient[DaytonaSandboxClientOptions]):
+ """Daytona sandbox client managing sandbox lifecycle via AsyncDaytona."""
+
+ backend_id = "daytona"
+ _instrumentation: Instrumentation
+
+ def __init__(
+ self,
+ *,
+ api_key: str | None = None,
+ api_url: str | None = None,
+ instrumentation: Instrumentation | None = None,
+ dependencies: Dependencies | None = None,
+ ) -> None:
+ AsyncDaytona, DaytonaConfig, _, _ = _import_daytona_sdk()
+ config = DaytonaConfig(api_key=api_key, api_url=api_url) if (api_key or api_url) else None
+ self._daytona = AsyncDaytona(config)
+ self._instrumentation = instrumentation or Instrumentation()
+ self._dependencies = dependencies
+
+ async def _build_create_params(
+ self,
+ *,
+ sandbox_snapshot_name: str | None,
+ image: str | None,
+ env_vars: dict[str, str] | None,
+ manifest: Manifest,
+ name: str | None = None,
+ resources: DaytonaSandboxResources | None = None,
+ auto_stop_interval: int | None = None,
+ ) -> Any:
+ _, _, CreateSandboxFromSnapshotParams, CreateSandboxFromImageParams = _import_daytona_sdk()
+ base_envs = dict(env_vars or {})
+ creation_envs = base_envs or None
+
+ if sandbox_snapshot_name:
+ return CreateSandboxFromSnapshotParams(
+ snapshot=sandbox_snapshot_name,
+ env_vars=creation_envs,
+ name=name,
+ auto_stop_interval=auto_stop_interval,
+ )
+
+ if image:
+ sandbox_resources = None
+ if resources is not None and any(
+ v is not None for v in (resources.cpu, resources.memory, resources.disk)
+ ):
+ Resources = _import_sdk_resources()
+ sandbox_resources = Resources(
+ cpu=resources.cpu,
+ memory=resources.memory,
+ disk=resources.disk,
+ )
+ return CreateSandboxFromImageParams(
+ image=image,
+ env_vars=creation_envs,
+ name=name,
+ resources=sandbox_resources,
+ auto_stop_interval=auto_stop_interval,
+ )
+
+ return CreateSandboxFromSnapshotParams(
+ env_vars=creation_envs,
+ name=name,
+ auto_stop_interval=auto_stop_interval,
+ )
+
+ async def create(
+ self,
+ *,
+ snapshot: SnapshotSpec | SnapshotBase | None = None,
+ manifest: Manifest | None = None,
+ options: DaytonaSandboxClientOptions,
+ ) -> SandboxSession:
+ if manifest is None:
+ manifest = Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT)
+
+ timeouts_in = options.timeouts
+ if isinstance(timeouts_in, DaytonaSandboxTimeouts):
+ timeouts = timeouts_in
+ elif timeouts_in is None:
+ timeouts = DaytonaSandboxTimeouts()
+ else:
+ timeouts = DaytonaSandboxTimeouts.model_validate(timeouts_in)
+
+ session_id = uuid.uuid4()
+ sandbox_name = options.name or str(session_id)
+
+ params = await self._build_create_params(
+ sandbox_snapshot_name=options.sandbox_snapshot_name,
+ image=options.image,
+ env_vars=options.env_vars,
+ manifest=manifest,
+ name=sandbox_name,
+ resources=options.resources,
+ auto_stop_interval=options.auto_stop_interval,
+ )
+ daytona_sandbox = await self._daytona.create(params, timeout=options.create_timeout)
+
+ snapshot_instance = resolve_snapshot(snapshot, str(session_id))
+ state = DaytonaSandboxSessionState(
+ session_id=session_id,
+ manifest=manifest,
+ snapshot=snapshot_instance,
+ sandbox_id=daytona_sandbox.id,
+ sandbox_snapshot_name=options.sandbox_snapshot_name,
+ image=options.image,
+ base_env_vars=dict(options.env_vars or {}),
+ pause_on_exit=options.pause_on_exit,
+ create_timeout=options.create_timeout,
+ start_timeout=options.start_timeout,
+ name=sandbox_name,
+ resources=options.resources,
+ auto_stop_interval=options.auto_stop_interval,
+ timeouts=timeouts,
+ exposed_ports=options.exposed_ports,
+ exposed_port_url_ttl_s=options.exposed_port_url_ttl_s,
+ )
+ inner = DaytonaSandboxSession.from_state(state, sandbox=daytona_sandbox)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ async def close(self) -> None:
+ """Close the underlying AsyncDaytona HTTP client session."""
+ await self._daytona.close()
+
+ async def __aenter__(self) -> DaytonaSandboxClient:
+ return self
+
+ async def __aexit__(self, *_: object) -> None:
+ await self.close()
+
+ async def delete(self, session: SandboxSession) -> SandboxSession:
+ inner = session._inner
+ if not isinstance(inner, DaytonaSandboxSession):
+ raise TypeError("DaytonaSandboxClient.delete expects a DaytonaSandboxSession")
+ try:
+ await inner.shutdown()
+ except Exception:
+ pass
+ return session
+
+ async def resume(
+ self,
+ state: SandboxSessionState,
+ ) -> SandboxSession:
+ if not isinstance(state, DaytonaSandboxSessionState):
+ raise TypeError("DaytonaSandboxClient.resume expects a DaytonaSandboxSessionState")
+
+ daytona_sandbox = None
+ reconnected = False
+ try:
+ daytona_sandbox = await self._daytona.get(state.sandbox_id)
+ SandboxState = _import_sandbox_state()
+ if getattr(daytona_sandbox, "state", None) != SandboxState.STARTED:
+ await daytona_sandbox.start(timeout=state.start_timeout)
+ reconnected = True
+ except Exception as e:
+ logger.debug("daytona sandbox get() failed, will recreate: %s", e)
+
+ if not reconnected or daytona_sandbox is None:
+ params = await self._build_create_params(
+ sandbox_snapshot_name=state.sandbox_snapshot_name,
+ image=state.image,
+ env_vars=state.base_env_vars,
+ manifest=state.manifest,
+ name=state.name,
+ resources=state.resources,
+ auto_stop_interval=state.auto_stop_interval,
+ )
+ daytona_sandbox = await self._daytona.create(params, timeout=state.create_timeout)
+ state.sandbox_id = daytona_sandbox.id
+ state.workspace_root_ready = False
+
+ inner = DaytonaSandboxSession.from_state(state, sandbox=daytona_sandbox)
+ inner._set_start_state_preserved(reconnected, system=reconnected)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
+ return DaytonaSandboxSessionState.model_validate(payload)
+
+
+__all__ = [
+ "DEFAULT_DAYTONA_WORKSPACE_ROOT",
+ "DaytonaSandboxResources",
+ "DaytonaSandboxClient",
+ "DaytonaSandboxClientOptions",
+ "DaytonaSandboxSession",
+ "DaytonaSandboxSessionState",
+ "DaytonaSandboxTimeouts",
+]
diff --git a/src/agents/extensions/sandbox/e2b/__init__.py b/src/agents/extensions/sandbox/e2b/__init__.py
new file mode 100644
index 00000000..53100454
--- /dev/null
+++ b/src/agents/extensions/sandbox/e2b/__init__.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from .mounts import E2BCloudBucketMountStrategy
+from .sandbox import (
+ E2BSandboxClient,
+ E2BSandboxClientOptions,
+ E2BSandboxSession,
+ E2BSandboxSessionState,
+ E2BSandboxTimeouts,
+ E2BSandboxType,
+ _E2BSandboxFactoryAPI,
+ _encode_e2b_snapshot_ref,
+ _import_sandbox_class,
+ _sandbox_connect,
+)
+
+__all__ = [
+ "_E2BSandboxFactoryAPI",
+ "_encode_e2b_snapshot_ref",
+ "_import_sandbox_class",
+ "_sandbox_connect",
+ "E2BCloudBucketMountStrategy",
+ "E2BSandboxClient",
+ "E2BSandboxClientOptions",
+ "E2BSandboxSession",
+ "E2BSandboxSessionState",
+ "E2BSandboxTimeouts",
+ "E2BSandboxType",
+]
diff --git a/src/agents/extensions/sandbox/e2b/mounts.py b/src/agents/extensions/sandbox/e2b/mounts.py
new file mode 100644
index 00000000..5b552028
--- /dev/null
+++ b/src/agents/extensions/sandbox/e2b/mounts.py
@@ -0,0 +1,200 @@
+"""Mount strategy for E2B sandboxes."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Literal
+
+from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase
+from ....sandbox.entries.mounts.patterns import RcloneMountPattern
+from ....sandbox.errors import MountConfigError
+from ....sandbox.materialization import MaterializedFile
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+
+_APT = "DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0"
+_RCLONE_CHECK = "command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone"
+_INSTALL_RCLONE_COMMANDS = (
+ f"{_APT} update -qq",
+ f"{_APT} install -y -qq curl unzip ca-certificates",
+ "curl -fsSL https://rclone.org/install.sh | bash",
+)
+_FUSE_ALLOW_OTHER = (
+ "chmod a+rw /dev/fuse && "
+ "touch /etc/fuse.conf && "
+ "(grep -qxF user_allow_other /etc/fuse.conf || "
+ "printf '\\nuser_allow_other\\n' >> /etc/fuse.conf)"
+)
+
+
+async def _ensure_fuse_support(session: BaseSandboxSession) -> None:
+ check = await session.exec(
+ "sh",
+ "-lc",
+ "test -c /dev/fuse && grep -qw fuse /proc/filesystems && "
+ "(command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1)",
+ shell=False,
+ )
+ if not check.ok():
+ raise MountConfigError(
+ message="E2B cloud bucket mounts require FUSE support and fusermount",
+ context={"missing": "fuse"},
+ )
+
+ chmod_result = await session.exec(
+ "sh",
+ "-lc",
+ _FUSE_ALLOW_OTHER,
+ shell=False,
+ timeout=30,
+ user="root",
+ )
+ if not chmod_result.ok():
+ raise MountConfigError(
+ message="failed to make /dev/fuse accessible",
+ context={"exit_code": chmod_result.exit_code},
+ )
+
+
+async def _ensure_rclone(session: BaseSandboxSession) -> None:
+ rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False)
+ if rclone.ok():
+ return
+
+ apt = await session.exec("sh", "-lc", "command -v apt-get >/dev/null 2>&1", shell=False)
+ if not apt.ok():
+ raise MountConfigError(
+ message="rclone is not installed and apt-get is unavailable; preinstall rclone",
+ context={"package": "rclone"},
+ )
+
+ for command in _INSTALL_RCLONE_COMMANDS:
+ install = await session.exec("sh", "-lc", command, shell=False, timeout=300, user="root")
+ if not install.ok():
+ raise MountConfigError(
+ message="failed to install rclone",
+ context={"package": "rclone", "exit_code": install.exit_code},
+ )
+
+ rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False)
+ if not rclone.ok():
+ raise MountConfigError(
+ message="rclone was installed but is still not available on PATH",
+ context={"package": "rclone"},
+ )
+
+
+async def _default_user_ids(session: BaseSandboxSession) -> tuple[str, str] | None:
+ result = await session.exec("sh", "-lc", "id -u; id -g", shell=False, timeout=30)
+ if not result.ok():
+ return None
+
+ lines = result.stdout.decode("utf-8", errors="replace").splitlines()
+ if len(lines) < 2 or not lines[0].isdigit() or not lines[1].isdigit():
+ return None
+ return lines[0], lines[1]
+
+
+def _append_option(args: list[str], option: str, *values: str) -> None:
+ if option not in args:
+ args.extend([option, *values])
+
+
+async def _rclone_pattern_for_session(
+ session: BaseSandboxSession,
+ pattern: RcloneMountPattern,
+) -> RcloneMountPattern:
+ if pattern.mode != "fuse":
+ return pattern
+
+ extra_args = list(pattern.extra_args)
+ _append_option(extra_args, "--allow-other")
+ user_ids = await _default_user_ids(session)
+ if user_ids is not None:
+ uid, gid = user_ids
+ _append_option(extra_args, "--uid", uid)
+ _append_option(extra_args, "--gid", gid)
+
+ return pattern.model_copy(update={"extra_args": extra_args})
+
+
+def _assert_e2b_session(session: BaseSandboxSession) -> None:
+ if type(session).__name__ != "E2BSandboxSession":
+ raise MountConfigError(
+ message="e2b cloud bucket mounts require an E2BSandboxSession",
+ context={"session_type": type(session).__name__},
+ )
+
+
+class E2BCloudBucketMountStrategy(MountStrategyBase):
+ """Mount cloud buckets in E2B sandboxes via rclone."""
+
+ type: Literal["e2b_cloud_bucket"] = "e2b_cloud_bucket"
+ pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse")
+
+ def _delegate(self) -> InContainerMountStrategy:
+ return InContainerMountStrategy(pattern=self.pattern)
+
+ async def _delegate_for_session(self, session: BaseSandboxSession) -> InContainerMountStrategy:
+ return InContainerMountStrategy(
+ pattern=await _rclone_pattern_for_session(session, self.pattern)
+ )
+
+ def validate_mount(self, mount: Mount) -> None:
+ self._delegate().validate_mount(mount)
+
+ async def activate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> list[MaterializedFile]:
+ _assert_e2b_session(session)
+ if self.pattern.mode == "fuse":
+ await _ensure_fuse_support(session)
+ await _ensure_rclone(session)
+ delegate = await self._delegate_for_session(session)
+ return await delegate.activate(mount, session, dest, base_dir)
+
+ async def deactivate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> None:
+ _assert_e2b_session(session)
+ await self._delegate().deactivate(mount, session, dest, base_dir)
+
+ async def teardown_for_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_e2b_session(session)
+ await self._delegate().teardown_for_snapshot(mount, session, path)
+
+ async def restore_after_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_e2b_session(session)
+ if self.pattern.mode == "fuse":
+ await _ensure_fuse_support(session)
+ await _ensure_rclone(session)
+ delegate = await self._delegate_for_session(session)
+ await delegate.restore_after_snapshot(mount, session, path)
+
+ def build_docker_volume_driver_config(
+ self,
+ mount: Mount,
+ ) -> tuple[str, dict[str, str], bool] | None:
+ return None
+
+
+__all__ = [
+ "E2BCloudBucketMountStrategy",
+]
diff --git a/src/agents/extensions/sandbox/e2b/sandbox.py b/src/agents/extensions/sandbox/e2b/sandbox.py
new file mode 100644
index 00000000..3ed2d1fc
--- /dev/null
+++ b/src/agents/extensions/sandbox/e2b/sandbox.py
@@ -0,0 +1,1734 @@
+"""
+E2B sandbox (https://e2b.dev) implementation.
+
+Create an E2B account and export `E2B_API_KEY` to configure E2B locally.
+
+This module provides an E2B-backed sandbox client/session implementation backed by
+the E2B SDK sandbox classes.
+
+Note: The `e2b` and `e2b-code-interpreter` dependencies are intended to be optional
+(installed via extras), so package-level exports should guard imports of this module.
+Within this module, E2B SDK imports are lazy so users without the extra can still
+import the package.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import binascii
+import inspect
+import io
+import json
+import logging
+import shlex
+import time
+import uuid
+from collections import deque
+from collections.abc import Awaitable, Callable, Mapping, Sequence
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Any, Literal, NoReturn, cast
+from urllib.parse import urlsplit
+
+from pydantic import BaseModel, Field
+
+from ....sandbox.entries import Mount
+from ....sandbox.errors import (
+ ExecNonZeroError,
+ ExecTimeoutError,
+ ExecTransportError,
+ ExposedPortUnavailableError,
+ WorkspaceArchiveReadError,
+ WorkspaceArchiveWriteError,
+ WorkspaceReadNotFoundError,
+ WorkspaceStartError,
+ WorkspaceWriteTypeError,
+)
+from ....sandbox.manifest import Manifest
+from ....sandbox.session import SandboxSession, SandboxSessionState
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+from ....sandbox.session.dependencies import Dependencies
+from ....sandbox.session.manager import Instrumentation
+from ....sandbox.session.pty_types import (
+ PTY_PROCESSES_MAX,
+ PTY_PROCESSES_WARNING,
+ PtyExecUpdate,
+ allocate_pty_process_id,
+ clamp_pty_yield_time_ms,
+ process_id_to_prune_from_meta,
+ resolve_pty_write_yield_time_ms,
+ truncate_text_by_tokens,
+)
+from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
+from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
+from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
+from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
+from ....sandbox.util.retry import (
+ TRANSIENT_HTTP_STATUS_CODES,
+ exception_chain_contains_type,
+ exception_chain_has_status_code,
+ iter_exception_chain,
+ retry_async,
+)
+from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
+
+WorkspacePersistenceMode = Literal["tar", "snapshot"]
+E2BTimeoutAction = Literal["kill", "pause"]
+
+_WORKSPACE_PERSISTENCE_TAR: WorkspacePersistenceMode = "tar"
+_WORKSPACE_PERSISTENCE_SNAPSHOT: WorkspacePersistenceMode = "snapshot"
+
+# Magic prefix for native E2B snapshot payloads that cannot be represented as tar bytes.
+_E2B_SANDBOX_SNAPSHOT_MAGIC = b"E2B_SANDBOX_SNAPSHOT_V1\n"
+logger = logging.getLogger(__name__)
+
+
+def _raise_e2b_exec_error(
+ exc: BaseException,
+ *,
+ command: Sequence[str | Path],
+ timeout: float | None,
+ timeout_exc: type[BaseException] | None,
+) -> NoReturn:
+ """Classify an E2B exception and raise the appropriate ExecFailureError."""
+ # Build context from the exception chain.
+ ctx: dict[str, object] = {}
+ msg = str(exc).strip()
+ ctx["provider_error"] = msg if msg else type(exc).__name__
+ for attr in ("stdout", "stderr"):
+ val = next(
+ (
+ str(v).strip()
+ for c in iter_exception_chain(exc)
+ if (v := getattr(c, attr, None)) and str(v).strip()
+ ),
+ None,
+ )
+ if val:
+ ctx[attr] = val
+
+ chain = list(iter_exception_chain(exc))
+
+ # Sandbox gone — always a transport error.
+ if any("sandbox" in str(c).lower() and "not found" in str(c).lower() for c in chain):
+ ctx.setdefault("reason", "sandbox_not_found")
+ raise ExecTransportError(command=command, context=ctx, cause=exc) from exc
+
+ # E2B timeout or httpcore read timeout.
+ is_timeout = timeout_exc is not None and exception_chain_contains_type(exc, (timeout_exc,))
+ if not is_timeout and any(
+ type(c).__name__ == "ReadTimeout" and type(c).__module__.startswith("httpcore")
+ for c in chain
+ ):
+ ctx.setdefault("reason", "stream_read_timeout")
+ is_timeout = True
+
+ if is_timeout:
+ raise ExecTimeoutError(
+ command=command,
+ timeout_s=timeout,
+ context=ctx,
+ cause=exc,
+ ) from exc
+
+ raise ExecTransportError(command=command, context=ctx, cause=exc) from exc
+
+
+def _encode_e2b_snapshot_ref(*, snapshot_id: str) -> bytes:
+ body = json.dumps({"snapshot_id": snapshot_id}, separators=(",", ":"), sort_keys=True).encode(
+ "utf-8"
+ )
+ return _E2B_SANDBOX_SNAPSHOT_MAGIC + body
+
+
+def _decode_e2b_snapshot_ref(raw: bytes) -> str | None:
+ if not raw.startswith(_E2B_SANDBOX_SNAPSHOT_MAGIC):
+ return None
+ body = raw[len(_E2B_SANDBOX_SNAPSHOT_MAGIC) :]
+ try:
+ obj = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return None
+ snapshot_id = obj.get("snapshot_id") if isinstance(obj, dict) else None
+ return snapshot_id if isinstance(snapshot_id, str) and snapshot_id else None
+
+
+class _E2BFilesAPI:
+ async def write(
+ self,
+ path: str,
+ data: bytes,
+ request_timeout: float | None = None,
+ ) -> object:
+ raise NotImplementedError
+
+ async def remove(self, path: str, request_timeout: float | None = None) -> object:
+ raise NotImplementedError
+
+ async def make_dir(self, path: str, request_timeout: float | None = None) -> object:
+ raise NotImplementedError
+
+ async def read(self, path: str, format: str = "bytes") -> object:
+ raise NotImplementedError
+
+
+class _E2BCommandsAPI:
+ async def run(
+ self,
+ command: str,
+ background: bool | None = None,
+ envs: dict[str, str] | None = None,
+ user: str | User | None = None,
+ cwd: str | None = None,
+ on_stdout: object | None = None,
+ on_stderr: object | None = None,
+ stdin: bool | None = None,
+ timeout: float | None = None,
+ request_timeout: float | None = None,
+ ) -> object:
+ raise NotImplementedError
+
+
+class _E2BPtyAPI:
+ async def create(
+ self,
+ *,
+ size: object,
+ cwd: str | None = None,
+ envs: dict[str, str] | None = None,
+ timeout: float | None = None,
+ on_data: object | None = None,
+ ) -> object:
+ raise NotImplementedError
+
+ async def send_stdin(
+ self,
+ pid: object,
+ data: bytes,
+ request_timeout: float | None = None,
+ ) -> object:
+ raise NotImplementedError
+
+
+class _E2BSandboxAPI:
+ sandbox_id: object
+ files: _E2BFilesAPI
+ commands: _E2BCommandsAPI
+ pty: _E2BPtyAPI
+ connection_config: object
+
+ async def pause(self) -> object:
+ raise NotImplementedError
+
+ async def kill(self) -> object:
+ raise NotImplementedError
+
+ async def is_running(self, request_timeout: float | None = None) -> object:
+ raise NotImplementedError
+
+ def get_host(self, port: int) -> str:
+ raise NotImplementedError
+
+ async def create_snapshot(self, **opts: object) -> object:
+ raise NotImplementedError
+
+
+class _E2BSandboxFactoryAPI:
+ async def create(
+ self,
+ *,
+ template: str | None = None,
+ timeout: int | None = None,
+ metadata: dict[str, str] | None = None,
+ envs: dict[str, str] | None = None,
+ secure: bool = True,
+ allow_internet_access: bool = True,
+ network: dict[str, object] | None = None,
+ lifecycle: dict[str, object] | None = None,
+ mcp: dict[str, dict[str, str]] | None = None,
+ ) -> object:
+ raise NotImplementedError
+
+ async def _cls_connect(
+ self,
+ *,
+ sandbox_id: str,
+ timeout: int | None = None,
+ ) -> object:
+ raise NotImplementedError
+
+ async def _cls_connect_sandbox(
+ self,
+ *,
+ sandbox_id: str,
+ timeout: int | None = None,
+ ) -> object:
+ raise NotImplementedError
+
+
+# NOTE: We avoid importing `e2b_code_interpreter` or `e2b` at module import time so that users
+# without the optional dependency can still import the sandbox package (they just can't use the
+# E2B sandbox).
+
+
+class E2BSandboxType(str, Enum):
+ """Supported E2B sandbox interfaces."""
+
+ CODE_INTERPRETER = "e2b_code_interpreter"
+ E2B = "e2b"
+
+
+def _coerce_sandbox_type(value: E2BSandboxType | str | None) -> E2BSandboxType:
+ if value is None:
+ raise ValueError(
+ "E2BSandboxClientOptions.sandbox_type is required. "
+ "Use one of: e2b_code_interpreter, e2b."
+ )
+ if isinstance(value, E2BSandboxType):
+ return value
+ try:
+ return E2BSandboxType(value)
+ except ValueError as e:
+ raise ValueError(
+ "Invalid E2BSandboxClientOptions.sandbox_type. Use one of: e2b_code_interpreter, e2b."
+ ) from e
+
+
+def _import_sandbox_class(sandbox_type: E2BSandboxType) -> _E2BSandboxFactoryAPI:
+ if sandbox_type is E2BSandboxType.CODE_INTERPRETER:
+ module_name = "e2b_code_interpreter"
+ missing_msg = (
+ "E2BSandboxClient requires the optional `e2b-code-interpreter` dependency.\n"
+ "Install the E2B extra before using this sandbox backend."
+ )
+ else:
+ module_name = "e2b"
+ missing_msg = (
+ "E2BSandboxClient requires the optional `e2b` dependency.\n"
+ "Install the E2B extra before using this sandbox backend."
+ )
+
+ try:
+ module = __import__(module_name, fromlist=["AsyncSandbox"])
+ Sandbox = module.AsyncSandbox
+ except Exception as e: # pragma: no cover - exercised via unit tests with fakes
+ if module_name == "e2b":
+ try:
+ module = __import__("e2b.sandbox", fromlist=["AsyncSandbox"])
+ Sandbox = module.AsyncSandbox
+ except Exception:
+ raise ImportError(missing_msg) from e
+ else:
+ raise ImportError(missing_msg) from e
+
+ return cast(_E2BSandboxFactoryAPI, Sandbox)
+
+
+def _as_sandbox_api(sandbox: object) -> _E2BSandboxAPI:
+ return cast(_E2BSandboxAPI, sandbox)
+
+
+def _sandbox_id(sandbox: object) -> object:
+ return _as_sandbox_api(sandbox).sandbox_id
+
+
+async def _sandbox_write_file(
+ sandbox: object,
+ path: str,
+ data: bytes,
+ *,
+ request_timeout: float | None = None,
+) -> object:
+ return await _as_sandbox_api(sandbox).files.write(
+ path,
+ data,
+ request_timeout=request_timeout,
+ )
+
+
+async def _sandbox_remove_file(
+ sandbox: object,
+ path: str,
+ *,
+ request_timeout: float | None = None,
+) -> object:
+ return await _as_sandbox_api(sandbox).files.remove(path, request_timeout=request_timeout)
+
+
+async def _sandbox_make_dir(
+ sandbox: object,
+ path: str,
+ *,
+ request_timeout: float | None = None,
+) -> object:
+ return await _as_sandbox_api(sandbox).files.make_dir(path, request_timeout=request_timeout)
+
+
+async def _sandbox_read_file(sandbox: object, path: str, *, format: str = "bytes") -> object:
+ return await _as_sandbox_api(sandbox).files.read(path, format=format)
+
+
+async def _sandbox_run_command(
+ sandbox: object,
+ command: str,
+ *,
+ timeout: float | None = None,
+ cwd: str | None = None,
+ envs: dict[str, str] | None = None,
+ user: str | None = None,
+) -> object:
+ return await _as_sandbox_api(sandbox).commands.run(
+ command,
+ timeout=timeout,
+ cwd=cwd,
+ envs=envs,
+ user=user,
+ )
+
+
+async def _sandbox_pause(sandbox: object) -> object:
+ return await _as_sandbox_api(sandbox).pause()
+
+
+async def _sandbox_kill(sandbox: object) -> object:
+ return await _as_sandbox_api(sandbox).kill()
+
+
+async def _sandbox_is_running(sandbox: object, *, request_timeout: float | None = None) -> object:
+ return await _as_sandbox_api(sandbox).is_running(request_timeout=request_timeout)
+
+
+def _sandbox_get_host(sandbox: object, port: int) -> str:
+ return _as_sandbox_api(sandbox).get_host(port)
+
+
+async def _sandbox_create_snapshot(sandbox: object) -> object:
+ return await _as_sandbox_api(sandbox).create_snapshot()
+
+
+async def _sandbox_create(
+ sandbox_class: _E2BSandboxFactoryAPI,
+ *,
+ template: str | None = None,
+ timeout: int | None = None,
+ metadata: dict[str, str] | None = None,
+ envs: dict[str, str] | None = None,
+ secure: bool = True,
+ allow_internet_access: bool = True,
+ network: dict[str, object] | None = None,
+ lifecycle: dict[str, object] | None = None,
+ mcp: dict[str, dict[str, str]] | None = None,
+) -> object:
+ create_callable = cast(Callable[..., Awaitable[object]], sandbox_class.create)
+ try:
+ create_params: Mapping[str, inspect.Parameter] | None = inspect.signature(
+ sandbox_class.create
+ ).parameters
+ except (TypeError, ValueError):
+ create_params = None
+ accepts_var_kwargs = bool(
+ create_params
+ and any(param.kind == inspect.Parameter.VAR_KEYWORD for param in create_params.values())
+ )
+ create_kwargs: dict[str, object] = {
+ "template": template,
+ "timeout": timeout,
+ "metadata": metadata,
+ "envs": envs,
+ "secure": secure,
+ "allow_internet_access": allow_internet_access,
+ "network": network,
+ }
+ if mcp is not None:
+ create_kwargs["mcp"] = mcp
+
+ if lifecycle is not None and (
+ accepts_var_kwargs or (create_params is not None and "lifecycle" in create_params)
+ ):
+ create_kwargs["lifecycle"] = lifecycle
+
+ if create_params is not None and not accepts_var_kwargs:
+ create_kwargs = {key: value for key, value in create_kwargs.items() if key in create_params}
+
+ return await create_callable(**create_kwargs)
+
+
+def _e2b_lifecycle(
+ on_timeout: E2BTimeoutAction,
+ *,
+ auto_resume: bool,
+) -> dict[str, object]:
+ lifecycle: dict[str, object] = {"on_timeout": on_timeout}
+ if on_timeout == "pause":
+ lifecycle["auto_resume"] = auto_resume
+ return lifecycle
+
+
+async def _sandbox_connect(
+ sandbox_class: _E2BSandboxFactoryAPI,
+ *,
+ sandbox_id: str,
+ timeout: int | None = None,
+) -> object:
+ # In the Python SDK, `Sandbox._cls_connect(...)` returns the low-level API model, while the
+ # public classmethod variant `Sandbox.connect(...)` / private `_cls_connect_sandbox(...)`
+ # returns the full sandbox wrapper with `.files`, `.commands`, etc.
+ connect = getattr(sandbox_class, "connect", None)
+ if callable(connect):
+ try:
+ return await connect(sandbox_id=sandbox_id, timeout=timeout)
+ except TypeError:
+ pass
+
+ connect_sandbox = getattr(sandbox_class, "_cls_connect_sandbox", None)
+ if callable(connect_sandbox):
+ return await connect_sandbox(sandbox_id=sandbox_id, timeout=timeout)
+
+ return await sandbox_class._cls_connect(sandbox_id=sandbox_id, timeout=timeout)
+
+
+def _import_e2b_exceptions() -> Mapping[str, type[BaseException]]:
+ """Best-effort import of E2B exception classes for classification."""
+
+ try:
+ from e2b.exceptions import (
+ NotFoundException,
+ SandboxException,
+ TimeoutException,
+ )
+ except Exception: # pragma: no cover - handled by fallbacks
+ return {}
+
+ return {
+ "not_found": cast(type[BaseException], NotFoundException),
+ "sandbox": cast(type[BaseException], SandboxException),
+ "timeout": cast(type[BaseException], TimeoutException),
+ }
+
+
+def _import_command_exit_exception() -> type[BaseException] | None:
+ try:
+ from e2b.sandbox.commands.command_handle import (
+ CommandExitException,
+ )
+ except Exception: # pragma: no cover - handled by fallbacks
+ return None
+ return cast(type[BaseException], CommandExitException)
+
+
+def _retryable_persist_workspace_error_types() -> tuple[type[BaseException], ...]:
+ excs = _import_e2b_exceptions()
+ retryable: list[type[BaseException]] = []
+ timeout_exc = excs.get("timeout")
+ if timeout_exc is not None:
+ retryable.append(timeout_exc)
+ return tuple(retryable)
+
+
+class E2BSandboxTimeouts(BaseModel):
+ """Timeout configuration for E2B operations."""
+
+ # E2B commands default to a 60s timeout when `timeout=None`. Sandbox semantics
+ # for `timeout=None` are "no timeout", so we pass a large sentinel value instead.
+ exec_timeout_unbounded_s: float = Field(default=24 * 60 * 60, ge=1) # 24 hours
+
+ # Keepalive / is_running should be quick; if it does not return promptly,
+ # the sandbox is unhealthy.
+ keepalive_s: float = Field(default=5, ge=1)
+
+ # best-effort cleanup (e.g., removing temp tar files) should not block shutdown for long.
+ cleanup_s: float = Field(default=30, ge=1)
+
+ # fast, small ops like `mkdir -p` / `cat` / metadata-ish operations.
+ fast_op_s: float = Field(default=10, ge=1)
+
+ # uploading tar contents can take longer than fast ops.
+ file_upload_s: float = Field(default=30, ge=1)
+
+ # snapshot tar ops can be heavier on large workspaces.
+ snapshot_tar_s: float = Field(default=60, ge=1)
+
+
+class E2BSandboxClientOptions(BaseSandboxClientOptions):
+ """Client options for the E2B sandbox."""
+
+ type: Literal["e2b"] = "e2b"
+ sandbox_type: E2BSandboxType | str
+ template: str | None = None
+ timeout: int | None = None
+ metadata: dict[str, str] | None = None
+ envs: dict[str, str] | None = None
+ secure: bool = True
+ allow_internet_access: bool = True
+ timeouts: E2BSandboxTimeouts | dict[str, object] | None = None
+ pause_on_exit: bool = False
+ exposed_ports: tuple[int, ...] = ()
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR
+ on_timeout: E2BTimeoutAction = "pause"
+ auto_resume: bool = True
+ mcp: dict[str, dict[str, str]] | None = None
+
+ def __init__(
+ self,
+ sandbox_type: E2BSandboxType | str,
+ template: str | None = None,
+ timeout: int | None = None,
+ metadata: dict[str, str] | None = None,
+ envs: dict[str, str] | None = None,
+ secure: bool = True,
+ allow_internet_access: bool = True,
+ timeouts: E2BSandboxTimeouts | dict[str, object] | None = None,
+ pause_on_exit: bool = False,
+ exposed_ports: tuple[int, ...] = (),
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR,
+ on_timeout: E2BTimeoutAction = "pause",
+ auto_resume: bool = True,
+ mcp: dict[str, dict[str, str]] | None = None,
+ *,
+ type: Literal["e2b"] = "e2b",
+ ) -> None:
+ super().__init__(
+ type=type,
+ sandbox_type=sandbox_type,
+ template=template,
+ timeout=timeout,
+ metadata=metadata,
+ envs=envs,
+ secure=secure,
+ allow_internet_access=allow_internet_access,
+ timeouts=timeouts,
+ pause_on_exit=pause_on_exit,
+ exposed_ports=exposed_ports,
+ workspace_persistence=workspace_persistence,
+ on_timeout=on_timeout,
+ auto_resume=auto_resume,
+ mcp=mcp,
+ )
+
+
+class E2BSandboxSessionState(SandboxSessionState):
+ type: Literal["e2b"] = "e2b"
+ sandbox_id: str
+ sandbox_type: E2BSandboxType = Field(default=E2BSandboxType.E2B)
+ template: str | None = None
+ sandbox_timeout: int | None = None
+ metadata: dict[str, str] | None = None
+ base_envs: dict[str, str] = Field(default_factory=dict)
+ secure: bool = True
+ allow_internet_access: bool = True
+ timeouts: E2BSandboxTimeouts = Field(default_factory=E2BSandboxTimeouts)
+ pause_on_exit: bool = False
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR
+ on_timeout: E2BTimeoutAction = "pause"
+ auto_resume: bool = True
+ mcp: dict[str, dict[str, str]] | None = None
+
+
+@dataclass
+class _E2BPtyProcessEntry:
+ handle: object
+ tty: bool
+ output_chunks: deque[bytes] = field(default_factory=deque)
+ output_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ output_notify: asyncio.Event = field(default_factory=asyncio.Event)
+ last_used: float = field(default_factory=time.monotonic)
+
+
+@dataclass(frozen=True)
+class _E2BPtySize:
+ rows: int
+ cols: int
+
+
+class E2BSandboxSession(BaseSandboxSession):
+ """E2B-backed sandbox session implementation."""
+
+ state: E2BSandboxSessionState
+ _sandbox: _E2BSandboxAPI
+ _workspace_root_ready: bool
+ _pty_lock: asyncio.Lock
+ _pty_processes: dict[int, _E2BPtyProcessEntry]
+ _reserved_pty_process_ids: set[int]
+
+ def __init__(
+ self,
+ *,
+ state: E2BSandboxSessionState,
+ sandbox: object,
+ ) -> None:
+ self.state = state
+ self._sandbox = _as_sandbox_api(sandbox)
+ self._workspace_root_ready = state.workspace_root_ready
+ self._pty_lock = asyncio.Lock()
+ self._pty_processes = {}
+ self._reserved_pty_process_ids = set()
+
+ @classmethod
+ def from_state(
+ cls,
+ state: E2BSandboxSessionState,
+ *,
+ sandbox: object,
+ ) -> E2BSandboxSession:
+ return cls(state=state, sandbox=sandbox)
+
+ @property
+ def sandbox_id(self) -> str:
+ return self.state.sandbox_id
+
+ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
+ try:
+ host = _sandbox_get_host(self._sandbox, port)
+ except Exception as e:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "e2b", "detail": "get_host_failed"},
+ cause=e,
+ ) from e
+
+ endpoint = _e2b_endpoint_from_host(host)
+ if endpoint is None:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "e2b", "detail": "invalid_host", "host": host},
+ )
+ return endpoint
+
+ async def _normalize_path_for_io(self, path: Path | str) -> Path:
+ return await self._normalize_path_for_remote_io(path)
+
+ def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
+ return (RESOLVE_WORKSPACE_PATH_HELPER,)
+
+ def _current_runtime_helper_cache_key(self) -> object | None:
+ return self.state.sandbox_id
+
+ async def _resolved_envs(self) -> dict[str, str]:
+ manifest_envs = await self.state.manifest.environment.resolve()
+ # Manifest envs take precedence over base envs supplied via client options.
+ return {**self.state.base_envs, **manifest_envs}
+
+ def _coerce_exec_timeout(self, timeout_s: float | None) -> float:
+ if timeout_s is None:
+ return float(self.state.timeouts.exec_timeout_unbounded_s)
+ if timeout_s <= 0:
+ # Sandbox timeout cannot be <= 0; use 1s and rely on caller semantics.
+ return 1.0
+ return float(timeout_s)
+
+ async def _ensure_dir(self, path: Path, *, reason: str) -> None:
+ """Create a directory using the E2B Files API."""
+ if path == Path("/"):
+ return
+ try:
+ await _sandbox_make_dir(
+ self._sandbox,
+ str(path),
+ request_timeout=self.state.timeouts.fast_op_s,
+ )
+ except Exception as e: # pragma: no cover - exercised via unit tests with fakes
+ raise WorkspaceArchiveWriteError(path=path, context={"reason": reason}, cause=e) from e
+
+ async def _ensure_workspace_root(self) -> None:
+ """Ensure the workspace root exists before materialization starts."""
+ await self._ensure_dir(Path(self.state.manifest.root), reason="root_make_failed")
+
+ async def _prepare_workspace_root_for_exec(self) -> None:
+ """Create the workspace root through the command API before using it as `cwd`."""
+ root = str(Path(self.state.manifest.root))
+ envs = await self._resolved_envs()
+ result = await _sandbox_run_command(
+ self._sandbox,
+ f"mkdir -p -- {shlex.quote(root)}",
+ timeout=self.state.timeouts.fast_op_s,
+ cwd="/",
+ envs=envs,
+ )
+ exit_code = int(getattr(result, "exit_code", 0) or 0)
+ if exit_code != 0:
+ raise WorkspaceStartError(
+ path=Path(self.state.manifest.root),
+ context={
+ "reason": "workspace_root_nonzero_exit",
+ "exit_code": exit_code,
+ "stderr": str(getattr(result, "stderr", "") or ""),
+ },
+ )
+ self._workspace_root_ready = True
+
+ def _mark_workspace_root_ready_from_probe(self) -> None:
+ super()._mark_workspace_root_ready_from_probe()
+ self._workspace_root_ready = True
+
+ async def _prepare_backend_workspace(self) -> None:
+ try:
+ if self._workspace_state_preserved_on_start():
+ # Reconnected sandboxes may have durable workspace contents; the base start flow
+ # probes before this provider creates the root for future exec calls.
+ if not self._workspace_root_ready:
+ await self._prepare_workspace_root_for_exec()
+ else:
+ # Fresh or recreated sandboxes need the workspace root created before snapshot
+ # hydration or full manifest materialization can write into it.
+ await self._ensure_workspace_root()
+ await self._prepare_workspace_root_for_exec()
+ except WorkspaceStartError:
+ raise
+ except Exception as e:
+ raise WorkspaceStartError(path=Path(self.state.manifest.root), cause=e) from e
+
+ async def _after_start(self) -> None:
+ # Native E2B snapshot hydration can replace the sandbox and sandbox id; reinstall runtime
+ # helpers only when the helper cache now points at a different backend.
+ if self._runtime_helper_cache_key != self._current_runtime_helper_cache_key():
+ await self._ensure_runtime_helpers()
+
+ async def _shutdown_backend(self) -> None:
+ # Best-effort kill of the remote sandbox.
+ try:
+ if self.state.pause_on_exit:
+ await _sandbox_pause(self._sandbox)
+ else:
+ await _sandbox_kill(self._sandbox)
+ except Exception as e:
+ if self.state.pause_on_exit:
+ logger.warning(
+ "Failed to pause E2B sandbox on shutdown; falling back to kill.",
+ extra={
+ "sandbox_id": self.state.sandbox_id,
+ "pause_on_exit": self.state.pause_on_exit,
+ },
+ exc_info=e,
+ )
+ try:
+ await _sandbox_kill(self._sandbox)
+ except Exception as kill_exc:
+ logger.warning(
+ "Failed to kill E2B sandbox after pause fallback failure.",
+ extra={
+ "sandbox_id": self.state.sandbox_id,
+ "pause_on_exit": self.state.pause_on_exit,
+ },
+ exc_info=kill_exc,
+ )
+ else:
+ logger.warning(
+ "Failed to kill E2B sandbox on shutdown.",
+ extra={
+ "sandbox_id": self.state.sandbox_id,
+ "pause_on_exit": self.state.pause_on_exit,
+ },
+ exc_info=e,
+ )
+
+ async def _exec_internal(
+ self,
+ *command: str | Path,
+ timeout: float | None = None,
+ ) -> ExecResult:
+ command_list = [str(c) for c in command]
+ envs = await self._resolved_envs()
+ cwd = self.state.manifest.root if self._workspace_root_ready else None
+ user: str | None = None
+ if command_list and command_list[0] == "sudo" and len(command_list) >= 4:
+ # Handle the `sudo -u -- ...` prefix introduced by SandboxSession.exec.
+ if command_list[1] == "-u" and command_list[3] == "--":
+ user = command_list[2]
+ command_list = command_list[4:]
+
+ cmd_str = shlex.join(command_list)
+ exec_timeout = self._coerce_exec_timeout(timeout)
+
+ e2b_exc = _import_e2b_exceptions()
+ timeout_exc = e2b_exc.get("timeout")
+ command_exit_exc = _import_command_exit_exception()
+
+ try:
+ result = await _sandbox_run_command(
+ self._sandbox,
+ cmd_str,
+ timeout=exec_timeout,
+ cwd=cwd,
+ envs=envs,
+ user=user,
+ )
+ return ExecResult(
+ stdout=str(getattr(result, "stdout", "") or "").encode("utf-8", errors="replace"),
+ stderr=str(getattr(result, "stderr", "") or "").encode("utf-8", errors="replace"),
+ exit_code=int(getattr(result, "exit_code", 0) or 0),
+ )
+ except Exception as e: # pragma: no cover - exercised via unit tests with fakes
+ if command_exit_exc is not None and isinstance(e, command_exit_exc):
+ exit_code = int(getattr(e, "exit_code", 1) or 1)
+ stdout = str(getattr(e, "stdout", "") or "")
+ stderr = str(getattr(e, "stderr", "") or "")
+ return ExecResult(
+ stdout=stdout.encode("utf-8", errors="replace"),
+ stderr=stderr.encode("utf-8", errors="replace"),
+ exit_code=exit_code,
+ )
+
+ _raise_e2b_exec_error(
+ e,
+ command=command,
+ timeout=timeout,
+ timeout_exc=timeout_exc,
+ )
+
+ def supports_pty(self) -> bool:
+ return True
+
+ 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:
+ sanitized_command = self._prepare_exec_command(*command, shell=shell, user=user)
+ command_text = shlex.join(str(part) for part in sanitized_command)
+ envs = await self._resolved_envs()
+ cwd = self.state.manifest.root if self._workspace_root_ready else None
+ exec_timeout = self._coerce_exec_timeout(timeout)
+ e2b_exc = _import_e2b_exceptions()
+ timeout_exc = e2b_exc.get("timeout")
+
+ entry = _E2BPtyProcessEntry(handle=None, tty=tty)
+
+ async def _append_output(payload: bytes | bytearray | str | object) -> None:
+ if isinstance(payload, bytes):
+ chunk = payload
+ elif isinstance(payload, bytearray):
+ chunk = bytes(payload)
+ elif isinstance(payload, str):
+ chunk = payload.encode("utf-8", errors="replace")
+ else:
+ chunk = str(payload).encode("utf-8", errors="replace")
+
+ async with entry.output_lock:
+ entry.output_chunks.append(chunk)
+ entry.output_notify.set()
+
+ registered = False
+ pruned_entry: _E2BPtyProcessEntry | None = None
+ process_id = 0
+ process_count = 0
+ try:
+ if tty:
+ handle = await self._sandbox.pty.create(
+ size=_E2BPtySize(rows=24, cols=80),
+ cwd=cwd,
+ envs=envs,
+ timeout=exec_timeout,
+ on_data=_append_output,
+ )
+ entry.handle = handle
+ await self._sandbox.pty.send_stdin(
+ cast(Any, handle).pid,
+ f"{command_text}\n".encode(),
+ request_timeout=self.state.timeouts.fast_op_s,
+ )
+ else:
+ handle = await self._sandbox.commands.run(
+ command_text,
+ background=True,
+ cwd=cwd,
+ envs=envs,
+ timeout=exec_timeout,
+ stdin=False,
+ on_stdout=_append_output,
+ on_stderr=_append_output,
+ )
+ entry.handle = handle
+ async with self._pty_lock:
+ process_id = allocate_pty_process_id(self._reserved_pty_process_ids)
+ self._reserved_pty_process_ids.add(process_id)
+ pruned_entry = self._prune_pty_processes_if_needed()
+ self._pty_processes[process_id] = entry
+ process_count = len(self._pty_processes)
+ registered = True
+ except asyncio.CancelledError:
+ if not registered and entry.handle is not None:
+ await self._terminate_pty_entry(entry)
+ raise
+ except Exception as e:
+ if not registered and entry.handle is not None:
+ await self._terminate_pty_entry(entry)
+ if isinstance(e, ExecTransportError):
+ raise
+ _raise_e2b_exec_error(
+ e,
+ command=command,
+ timeout=timeout,
+ timeout_exc=timeout_exc,
+ )
+
+ if pruned_entry is not None:
+ await self._terminate_pty_entry(pruned_entry)
+
+ if process_count >= PTY_PROCESSES_WARNING:
+ logger.warning(
+ "PTY process count reached warning threshold: %s active sessions",
+ process_count,
+ )
+
+ yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms),
+ max_output_tokens=max_output_tokens,
+ )
+ return await self._finalize_pty_update(
+ process_id=process_id,
+ entry=entry,
+ output=output,
+ original_token_count=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:
+ async with self._pty_lock:
+ entry = self._resolve_pty_session_entry(
+ pty_processes=self._pty_processes,
+ session_id=session_id,
+ )
+
+ if chars:
+ if not entry.tty:
+ raise RuntimeError("stdin is not available for this process")
+ await self._sandbox.pty.send_stdin(
+ cast(Any, entry.handle).pid,
+ chars.encode("utf-8"),
+ request_timeout=self.state.timeouts.fast_op_s,
+ )
+ await asyncio.sleep(0.1)
+
+ yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=resolve_pty_write_yield_time_ms(
+ yield_time_ms=yield_time_ms, input_empty=chars == ""
+ ),
+ max_output_tokens=max_output_tokens,
+ )
+ entry.last_used = time.monotonic()
+ return await self._finalize_pty_update(
+ process_id=session_id,
+ entry=entry,
+ output=output,
+ original_token_count=original_token_count,
+ )
+
+ async def pty_terminate_all(self) -> None:
+ async with self._pty_lock:
+ entries = list(self._pty_processes.values())
+ self._pty_processes.clear()
+ self._reserved_pty_process_ids.clear()
+
+ for entry in entries:
+ await self._terminate_pty_entry(entry)
+
+ async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase:
+ if user is not None:
+ await self._check_read_with_exec(path, user=user)
+
+ workspace_path = await self._normalize_path_for_io(path)
+
+ e2b_exc = _import_e2b_exceptions()
+ not_found_exc = e2b_exc.get("not_found")
+
+ try:
+ content = await _sandbox_read_file(self._sandbox, str(workspace_path), format="bytes")
+ if isinstance(content, bytes | bytearray):
+ data = bytes(content)
+ elif isinstance(content, str):
+ data = content.encode("utf-8", errors="replace")
+ else:
+ data = str(content).encode("utf-8", errors="replace")
+ return io.BytesIO(data)
+ except Exception as e: # pragma: no cover - exercised via unit tests with fakes
+ if not_found_exc is not None and isinstance(e, not_found_exc):
+ raise WorkspaceReadNotFoundError(path=path, cause=e) from e
+ raise WorkspaceArchiveReadError(path=path, cause=e) from e
+
+ async def write(
+ self,
+ path: Path,
+ data: io.IOBase,
+ *,
+ user: str | User | None = None,
+ ) -> None:
+ if user is not None:
+ await self._check_write_with_exec(path, user=user)
+
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
+
+ workspace_path = await self._normalize_path_for_io(path)
+
+ try:
+ await _sandbox_write_file(
+ self._sandbox,
+ str(workspace_path),
+ bytes(payload),
+ request_timeout=self.state.timeouts.file_upload_s,
+ )
+ except Exception as e: # pragma: no cover - exercised via unit tests with fakes
+ raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
+
+ async def running(self) -> bool:
+ if not self._workspace_root_ready:
+ return False
+ try:
+ return bool(
+ await _sandbox_is_running(
+ self._sandbox,
+ request_timeout=self.state.timeouts.keepalive_s,
+ )
+ )
+ except Exception:
+ return False
+
+ async def mkdir(
+ self,
+ path: Path | str,
+ *,
+ parents: bool = False,
+ user: str | User | None = None,
+ ) -> None:
+ if user is not None:
+ path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
+ else:
+ path = await self._normalize_path_for_io(path)
+
+ if user is None and not parents:
+ parent = path.parent
+ test = await self.exec("test", "-d", str(parent), shell=False)
+ if not test.ok():
+ raise ExecNonZeroError(test, command=("test", "-d", str(parent)))
+ await self._ensure_dir(path, reason="mkdir_failed")
+
+ async def _collect_pty_output(
+ self,
+ *,
+ entry: _E2BPtyProcessEntry,
+ yield_time_ms: int,
+ max_output_tokens: int | None,
+ ) -> tuple[bytes, int | None]:
+ deadline = time.monotonic() + (yield_time_ms / 1000)
+ output = bytearray()
+
+ while True:
+ async with entry.output_lock:
+ while entry.output_chunks:
+ output.extend(entry.output_chunks.popleft())
+
+ if time.monotonic() >= deadline:
+ break
+
+ if self._entry_exit_code(entry) is not None:
+ async with entry.output_lock:
+ while entry.output_chunks:
+ output.extend(entry.output_chunks.popleft())
+ break
+
+ remaining_s = deadline - time.monotonic()
+ if remaining_s <= 0:
+ break
+
+ try:
+ await asyncio.wait_for(entry.output_notify.wait(), timeout=remaining_s)
+ except asyncio.TimeoutError:
+ break
+ entry.output_notify.clear()
+
+ text = output.decode("utf-8", errors="replace")
+ truncated_text, original_token_count = truncate_text_by_tokens(text, max_output_tokens)
+ return truncated_text.encode("utf-8", errors="replace"), original_token_count
+
+ async def _finalize_pty_update(
+ self,
+ *,
+ process_id: int,
+ entry: _E2BPtyProcessEntry,
+ output: bytes,
+ original_token_count: int | None,
+ ) -> PtyExecUpdate:
+ exit_code = self._entry_exit_code(entry)
+ live_process_id: int | None = process_id
+
+ if exit_code is not None:
+ async with self._pty_lock:
+ removed = self._pty_processes.pop(process_id, None)
+ self._reserved_pty_process_ids.discard(process_id)
+ if removed is not None:
+ await self._terminate_pty_entry(removed)
+ live_process_id = None
+
+ return PtyExecUpdate(
+ process_id=live_process_id,
+ output=output,
+ exit_code=exit_code,
+ original_token_count=original_token_count,
+ )
+
+ def _prune_pty_processes_if_needed(self) -> _E2BPtyProcessEntry | None:
+ if len(self._pty_processes) < PTY_PROCESSES_MAX:
+ return None
+
+ meta: list[tuple[int, float, bool]] = [
+ (process_id, entry.last_used, self._entry_exit_code(entry) is not None)
+ for process_id, entry in self._pty_processes.items()
+ ]
+ process_id = process_id_to_prune_from_meta(meta)
+ if process_id is None:
+ return None
+
+ self._reserved_pty_process_ids.discard(process_id)
+ return self._pty_processes.pop(process_id, None)
+
+ def _entry_exit_code(self, entry: _E2BPtyProcessEntry) -> int | None:
+ value = getattr(entry.handle, "exit_code", None)
+ if value is None:
+ return None
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+ async def _terminate_pty_entry(self, entry: _E2BPtyProcessEntry) -> None:
+ kill = getattr(entry.handle, "kill", None)
+ if callable(kill):
+ try:
+ await kill()
+ except Exception:
+ pass
+
+ def _tar_exclude_args(self) -> list[str]:
+ excludes: list[str] = []
+ for rel in sorted(self._persist_workspace_skip_relpaths(), key=lambda p: p.as_posix()):
+ rel_posix = rel.as_posix().lstrip("/")
+ if not rel_posix or rel_posix in {".", "/"}:
+ continue
+ excludes.append(f"--exclude={shlex.quote(rel_posix)}")
+ excludes.append(f"--exclude={shlex.quote(f'./{rel_posix}')}")
+ return excludes
+
+ @retry_async(
+ retry_if=lambda exc, self, tar_cmd: (
+ exception_chain_contains_type(exc, _retryable_persist_workspace_error_types())
+ or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES)
+ )
+ )
+ async def _run_persist_workspace_command(self, tar_cmd: str) -> str:
+ try:
+ envs = await self._resolved_envs()
+ result = await _sandbox_run_command(
+ self._sandbox,
+ tar_cmd,
+ timeout=self.state.timeouts.snapshot_tar_s,
+ cwd="/",
+ envs=envs,
+ )
+ exit_code = int(getattr(result, "exit_code", 0) or 0)
+ if exit_code != 0:
+ raise WorkspaceArchiveReadError(
+ path=Path(self.state.manifest.root),
+ context={
+ "reason": "snapshot_nonzero_exit",
+ "exit_code": exit_code,
+ "stderr": str(getattr(result, "stderr", "") or ""),
+ },
+ )
+ return str(getattr(result, "stdout", "") or "")
+ except WorkspaceArchiveReadError:
+ raise
+ except Exception as e: # pragma: no cover - exercised via unit tests with fakes
+ raise WorkspaceArchiveReadError(path=Path(self.state.manifest.root), cause=e) from e
+
+ async def persist_workspace(self) -> io.IOBase:
+ if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT:
+ return await self._persist_workspace_via_snapshot()
+ return await self._persist_workspace_via_tar()
+
+ async def _persist_workspace_via_snapshot(self) -> io.IOBase:
+ """
+ Persist with E2B's native sandbox snapshot API.
+
+ Fall back to tar when there are plain non-mount skip paths, because native snapshots
+ capture the whole sandbox and the E2B API does not provide path-level excludes.
+ """
+
+ root = Path(self.state.manifest.root)
+ if not hasattr(self._sandbox, "create_snapshot"):
+ return await self._persist_workspace_via_tar()
+ if self._native_snapshot_requires_tar_fallback():
+ return await self._persist_workspace_via_tar()
+
+ skip = self._persist_workspace_skip_relpaths()
+ mount_targets = self.state.manifest.ephemeral_mount_targets()
+ mount_skip_rel_paths: set[Path] = set()
+ for _mount_entry, mount_path in mount_targets:
+ try:
+ mount_skip_rel_paths.add(mount_path.relative_to(root))
+ except ValueError:
+ continue
+ if skip - mount_skip_rel_paths:
+ return await self._persist_workspace_via_tar()
+
+ unmounted_mounts: list[tuple[Mount, Path]] = []
+ unmount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in mount_targets:
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ unmount_error = WorkspaceArchiveReadError(path=root, cause=e)
+ break
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ snapshot_error: WorkspaceArchiveReadError | None = None
+ snapshot_id: str | None = None
+ if unmount_error is None:
+ try:
+ snap = await asyncio.wait_for(
+ _sandbox_create_snapshot(self._sandbox),
+ timeout=self.state.timeouts.snapshot_tar_s,
+ )
+ snapshot_id = getattr(snap, "snapshot_id", None)
+ if not isinstance(snapshot_id, str) or not snapshot_id:
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "native_snapshot_unexpected_return",
+ "type": type(snap).__name__,
+ },
+ )
+ except WorkspaceArchiveReadError as e:
+ snapshot_error = e
+ except Exception as e:
+ snapshot_error = WorkspaceArchiveReadError(
+ path=root, context={"reason": "native_snapshot_failed"}, cause=e
+ )
+
+ remount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ current_error = WorkspaceArchiveReadError(path=root, cause=e)
+ if remount_error is None:
+ remount_error = current_error
+ else:
+ additional_remount_errors = remount_error.context.setdefault(
+ "additional_remount_errors", []
+ )
+ assert isinstance(additional_remount_errors, list)
+ additional_remount_errors.append(
+ {
+ "message": current_error.message,
+ "cause_type": type(e).__name__,
+ "cause": str(e),
+ }
+ )
+
+ if remount_error is not None:
+ if snapshot_error is not None:
+ remount_error.context["snapshot_error_before_remount_corruption"] = {
+ "message": snapshot_error.message
+ }
+ raise remount_error
+ if unmount_error is not None:
+ raise unmount_error
+ if snapshot_error is not None:
+ raise snapshot_error
+
+ assert snapshot_id is not None
+ return io.BytesIO(_encode_e2b_snapshot_ref(snapshot_id=snapshot_id))
+
+ async def _persist_workspace_via_tar(self) -> io.IOBase:
+ def _error_context_summary(error: WorkspaceArchiveReadError) -> dict[str, str]:
+ summary = {"message": error.message}
+ if error.cause is not None:
+ summary["cause_type"] = type(error.cause).__name__
+ summary["cause"] = str(error.cause)
+ return summary
+
+ root = Path(self.state.manifest.root)
+ excludes = " ".join(self._tar_exclude_args())
+ tar_cmd = f"tar {excludes} -C {shlex.quote(str(root))} -cf - . | base64 -w0"
+ unmounted_mounts: list[tuple[Mount, Path]] = []
+ unmount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ unmount_error = WorkspaceArchiveReadError(path=root, cause=e)
+ break
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ snapshot_error: WorkspaceArchiveReadError | None = None
+ raw: bytes | None = None
+ if unmount_error is None:
+ try:
+ encoded = await self._run_persist_workspace_command(tar_cmd)
+ try:
+ raw = base64.b64decode(encoded.encode("utf-8"), validate=True)
+ except (binascii.Error, ValueError) as e:
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={"reason": "snapshot_invalid_base64"},
+ cause=e,
+ ) from e
+ except WorkspaceArchiveReadError as e:
+ snapshot_error = e
+
+ remount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ current_error = WorkspaceArchiveReadError(path=root, cause=e)
+ if remount_error is None:
+ remount_error = current_error
+ if unmount_error is not None:
+ remount_error.context["earlier_unmount_error"] = _error_context_summary(
+ unmount_error
+ )
+ else:
+ additional_remount_errors = remount_error.context.setdefault(
+ "additional_remount_errors", []
+ )
+ assert isinstance(additional_remount_errors, list)
+ additional_remount_errors.append(_error_context_summary(current_error))
+
+ if remount_error is not None:
+ if snapshot_error is not None:
+ remount_error.context["snapshot_error_before_remount_corruption"] = (
+ _error_context_summary(snapshot_error)
+ )
+ raise remount_error
+ if unmount_error is not None:
+ raise unmount_error
+ if snapshot_error is not None:
+ raise snapshot_error
+
+ assert raw is not None
+ return io.BytesIO(raw)
+
+ async def hydrate_workspace(self, data: io.IOBase) -> None:
+ root = Path(self.state.manifest.root)
+ tar_path = f"/tmp/sandbox-hydrate-{self.state.session_id.hex}.tar"
+
+ raw = data.read()
+ if isinstance(raw, str):
+ raw = raw.encode("utf-8")
+ if not isinstance(raw, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=Path(tar_path), actual_type=type(raw).__name__)
+
+ snapshot_id = _decode_e2b_snapshot_ref(bytes(raw))
+ if snapshot_id is not None:
+ try:
+ try:
+ await _sandbox_kill(self._sandbox)
+ except Exception:
+ pass
+
+ sandbox_type = _coerce_sandbox_type(self.state.sandbox_type)
+ SandboxClass = _import_sandbox_class(sandbox_type)
+ base_envs = dict(self.state.base_envs)
+ manifest_envs = await self.state.manifest.environment.resolve()
+ envs = {**base_envs, **manifest_envs} or None
+ network_config = _e2b_network_config(self.state.exposed_ports)
+
+ sandbox = await _sandbox_create(
+ SandboxClass,
+ template=snapshot_id,
+ timeout=self.state.sandbox_timeout,
+ metadata=self.state.metadata,
+ envs=envs,
+ secure=self.state.secure,
+ allow_internet_access=self.state.allow_internet_access,
+ network=network_config,
+ lifecycle=_e2b_lifecycle(
+ self.state.on_timeout, auto_resume=self.state.auto_resume
+ ),
+ mcp=self.state.mcp,
+ )
+ self._sandbox = _as_sandbox_api(sandbox)
+ self.state.sandbox_id = str(_sandbox_id(sandbox))
+ self._workspace_root_ready = True
+ return
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "native_snapshot_restore_failed",
+ "snapshot_id": snapshot_id,
+ },
+ cause=e,
+ ) from e
+
+ try:
+ validate_tar_bytes(bytes(raw))
+ except UnsafeTarMemberError as e:
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "unsafe_or_invalid_tar",
+ "member": e.member,
+ "detail": str(e),
+ },
+ cause=e,
+ ) from e
+
+ try:
+ await self._ensure_workspace_root()
+ envs = await self._resolved_envs()
+ await _sandbox_write_file(
+ self._sandbox,
+ tar_path,
+ bytes(raw),
+ request_timeout=self.state.timeouts.file_upload_s,
+ )
+ result = await _sandbox_run_command(
+ self._sandbox,
+ f"tar -C {shlex.quote(str(root))} -xf {shlex.quote(tar_path)}",
+ timeout=self.state.timeouts.snapshot_tar_s,
+ cwd="/",
+ envs=envs,
+ )
+ exit_code = int(getattr(result, "exit_code", 0) or 0)
+ if exit_code != 0:
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "hydrate_nonzero_exit",
+ "exit_code": exit_code,
+ "stderr": str(getattr(result, "stderr", "") or ""),
+ },
+ )
+ self._workspace_root_ready = True
+ except WorkspaceArchiveWriteError:
+ raise
+ except Exception as e: # pragma: no cover - exercised via unit tests with fakes
+ raise WorkspaceArchiveWriteError(path=root, cause=e) from e
+ finally:
+ try:
+ envs = await self._resolved_envs()
+ await _sandbox_run_command(
+ self._sandbox,
+ f"rm -f -- {shlex.quote(tar_path)}",
+ timeout=self.state.timeouts.cleanup_s,
+ cwd="/",
+ envs=envs,
+ )
+ except Exception:
+ pass
+
+
+class E2BSandboxClient(BaseSandboxClient[E2BSandboxClientOptions]):
+ backend_id = "e2b"
+ _instrumentation: Instrumentation
+
+ def __init__(
+ self,
+ *,
+ instrumentation: Instrumentation | None = None,
+ dependencies: Dependencies | None = None,
+ ) -> None:
+ self._instrumentation = instrumentation or Instrumentation()
+ self._dependencies = dependencies
+
+ async def create(
+ self,
+ *,
+ snapshot: SnapshotSpec | SnapshotBase | None = None,
+ manifest: Manifest | None = None,
+ options: E2BSandboxClientOptions,
+ ) -> SandboxSession:
+ if options is None:
+ raise ValueError("E2BSandboxClient.create requires options")
+ manifest = manifest or Manifest()
+
+ sandbox_type = _coerce_sandbox_type(options.sandbox_type)
+
+ timeouts_in = options.timeouts
+ if isinstance(timeouts_in, E2BSandboxTimeouts):
+ timeouts = timeouts_in
+ elif timeouts_in is None:
+ timeouts = E2BSandboxTimeouts()
+ else:
+ timeouts = E2BSandboxTimeouts.model_validate(timeouts_in)
+
+ base_envs = dict(options.envs or {})
+ manifest_envs = await manifest.environment.resolve()
+ envs = {**base_envs, **manifest_envs} or None
+ network_config = _e2b_network_config(options.exposed_ports)
+
+ workspace_persistence = options.workspace_persistence
+ if workspace_persistence not in (
+ _WORKSPACE_PERSISTENCE_TAR,
+ _WORKSPACE_PERSISTENCE_SNAPSHOT,
+ ):
+ raise ValueError(
+ "E2BSandboxClient.create requires workspace_persistence to be one of "
+ f"{_WORKSPACE_PERSISTENCE_TAR!r} or {_WORKSPACE_PERSISTENCE_SNAPSHOT!r}"
+ )
+
+ SandboxClass = _import_sandbox_class(sandbox_type)
+ sandbox = await _sandbox_create(
+ SandboxClass,
+ template=options.template,
+ timeout=options.timeout,
+ metadata=options.metadata,
+ envs=envs,
+ secure=options.secure,
+ allow_internet_access=options.allow_internet_access,
+ network=network_config,
+ lifecycle=_e2b_lifecycle(options.on_timeout, auto_resume=options.auto_resume),
+ mcp=options.mcp,
+ )
+
+ session_id = uuid.uuid4()
+ snapshot_instance = resolve_snapshot(snapshot, str(session_id))
+ state = E2BSandboxSessionState(
+ session_id=session_id,
+ manifest=manifest,
+ snapshot=snapshot_instance,
+ sandbox_id=str(_sandbox_id(sandbox)),
+ sandbox_type=sandbox_type,
+ template=options.template,
+ sandbox_timeout=options.timeout,
+ metadata=options.metadata,
+ base_envs=base_envs,
+ secure=options.secure,
+ allow_internet_access=options.allow_internet_access,
+ timeouts=timeouts,
+ pause_on_exit=options.pause_on_exit,
+ workspace_persistence=workspace_persistence,
+ on_timeout=options.on_timeout,
+ auto_resume=options.auto_resume,
+ mcp=options.mcp,
+ exposed_ports=options.exposed_ports,
+ )
+ inner = E2BSandboxSession.from_state(state, sandbox=sandbox)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ async def delete(self, session: SandboxSession) -> SandboxSession:
+ inner = session._inner
+ if not isinstance(inner, E2BSandboxSession):
+ raise TypeError("E2BSandboxClient.delete expects an E2BSandboxSession")
+ return session
+
+ async def resume(
+ self,
+ state: SandboxSessionState,
+ ) -> SandboxSession:
+ if not isinstance(state, E2BSandboxSessionState):
+ raise TypeError("E2BSandboxClient.resume expects an E2BSandboxSessionState")
+
+ sandbox_type = _coerce_sandbox_type(state.sandbox_type)
+ SandboxClass = _import_sandbox_class(sandbox_type)
+
+ base_envs = dict(state.base_envs)
+ manifest_envs = await state.manifest.environment.resolve()
+ envs = {**base_envs, **manifest_envs} or None
+ network_config = _e2b_network_config(state.exposed_ports)
+ preserves_timeout_paused_state = state.on_timeout == "pause"
+
+ sandbox: object
+ reconnected = False
+ try:
+ # `_cls_connect` is the current async entrypoint for re-attaching to a sandbox id.
+ sandbox = await _sandbox_connect(
+ SandboxClass,
+ sandbox_id=state.sandbox_id,
+ timeout=state.sandbox_timeout,
+ )
+ if not state.pause_on_exit and not preserves_timeout_paused_state:
+ is_running = await _sandbox_is_running(
+ sandbox, request_timeout=state.timeouts.keepalive_s
+ )
+ if not is_running:
+ raise RuntimeError("sandbox_not_running")
+ reconnected = True
+ except Exception:
+ sandbox = await _sandbox_create(
+ SandboxClass,
+ template=state.template,
+ timeout=state.sandbox_timeout,
+ metadata=state.metadata,
+ envs=envs,
+ secure=state.secure,
+ allow_internet_access=state.allow_internet_access,
+ network=network_config,
+ lifecycle=_e2b_lifecycle(state.on_timeout, auto_resume=state.auto_resume),
+ mcp=state.mcp,
+ )
+ state.sandbox_id = str(_sandbox_id(sandbox))
+ state.workspace_root_ready = False
+
+ inner = E2BSandboxSession.from_state(state, sandbox=sandbox)
+ inner._set_start_state_preserved(reconnected, system=reconnected)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
+ return E2BSandboxSessionState.model_validate(payload)
+
+
+__all__ = [
+ "E2BSandboxClient",
+ "E2BSandboxClientOptions",
+ "E2BSandboxSession",
+ "E2BSandboxSessionState",
+ "E2BSandboxTimeouts",
+ "E2BSandboxType",
+]
+
+
+def _e2b_network_config(exposed_ports: tuple[int, ...]) -> dict[str, object] | None:
+ if not exposed_ports:
+ return None
+ return {"allow_public_traffic": True}
+
+
+def _e2b_endpoint_from_host(host: str) -> ExposedPortEndpoint | None:
+ if not host:
+ return None
+
+ split = urlsplit(f"//{host}")
+ hostname = split.hostname
+ if hostname is None:
+ return None
+
+ explicit_port = split.port
+ if explicit_port is not None:
+ return ExposedPortEndpoint(host=hostname, port=explicit_port, tls=False)
+
+ return ExposedPortEndpoint(host=hostname, port=443, tls=True)
diff --git a/src/agents/extensions/sandbox/modal/__init__.py b/src/agents/extensions/sandbox/modal/__init__.py
new file mode 100644
index 00000000..45aaf643
--- /dev/null
+++ b/src/agents/extensions/sandbox/modal/__init__.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+import tarfile
+
+from ....sandbox.snapshot import resolve_snapshot
+from .mounts import ModalCloudBucketMountConfig, ModalCloudBucketMountStrategy
+from .sandbox import (
+ _DEFAULT_TIMEOUT_S,
+ _MODAL_STDIN_CHUNK_SIZE,
+ ModalImageSelector,
+ ModalSandboxClient,
+ ModalSandboxClientOptions,
+ ModalSandboxSelector,
+ ModalSandboxSession,
+ ModalSandboxSessionState,
+ _encode_modal_snapshot_ref,
+ _encode_snapshot_directory_ref,
+ _encode_snapshot_filesystem_ref,
+)
+
+__all__ = [
+ "_DEFAULT_TIMEOUT_S",
+ "_MODAL_STDIN_CHUNK_SIZE",
+ "_encode_modal_snapshot_ref",
+ "_encode_snapshot_directory_ref",
+ "_encode_snapshot_filesystem_ref",
+ "ModalCloudBucketMountConfig",
+ "ModalCloudBucketMountStrategy",
+ "ModalImageSelector",
+ "ModalSandboxClient",
+ "ModalSandboxClientOptions",
+ "ModalSandboxSelector",
+ "ModalSandboxSession",
+ "ModalSandboxSessionState",
+ "resolve_snapshot",
+ "tarfile",
+]
diff --git a/src/agents/extensions/sandbox/modal/mounts.py b/src/agents/extensions/sandbox/modal/mounts.py
new file mode 100644
index 00000000..a7dcb74a
--- /dev/null
+++ b/src/agents/extensions/sandbox/modal/mounts.py
@@ -0,0 +1,205 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+from ....sandbox.entries import GCSMount, Mount, R2Mount, S3Mount
+from ....sandbox.entries.mounts.base import MountStrategyBase
+from ....sandbox.errors import MountConfigError
+from ....sandbox.materialization import MaterializedFile
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+
+
+@dataclass(frozen=True)
+class ModalCloudBucketMountConfig:
+ """Backend-neutral config for Modal's native cloud bucket mounts."""
+
+ bucket_name: str
+ bucket_endpoint_url: str | None = None
+ key_prefix: str | None = None
+ credentials: dict[str, str] | None = None
+ secret_name: str | None = None
+ secret_environment_name: str | None = None
+ read_only: bool = True
+
+
+class ModalCloudBucketMountStrategy(MountStrategyBase):
+ type: Literal["modal_cloud_bucket"] = "modal_cloud_bucket"
+ secret_name: str | None = None
+ secret_environment_name: str | None = None
+
+ def validate_mount(self, mount: Mount) -> None:
+ _ = self._build_modal_cloud_bucket_mount_config(mount)
+
+ def supports_native_snapshot_detach(self, mount: Mount) -> bool:
+ _ = mount
+ return False
+
+ async def activate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> list[MaterializedFile]:
+ if type(session).__name__ != "ModalSandboxSession":
+ raise MountConfigError(
+ message="modal cloud bucket mounts are not supported by this sandbox backend",
+ context={"mount_type": mount.type, "session_type": type(session).__name__},
+ )
+ _ = (mount, session, dest, base_dir)
+ return []
+
+ async def deactivate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> None:
+ if type(session).__name__ != "ModalSandboxSession":
+ raise MountConfigError(
+ message="modal cloud bucket mounts are not supported by this sandbox backend",
+ context={"mount_type": mount.type, "session_type": type(session).__name__},
+ )
+ _ = (mount, session, dest, base_dir)
+ return None
+
+ async def teardown_for_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _ = (mount, session, path)
+ return None
+
+ async def restore_after_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _ = (mount, session, path)
+ return None
+
+ def build_docker_volume_driver_config(
+ self,
+ mount: Mount,
+ ) -> tuple[str, dict[str, str], bool] | None:
+ _ = mount
+ return None
+
+ def _build_modal_cloud_bucket_mount_config(
+ self,
+ mount: Mount,
+ ) -> ModalCloudBucketMountConfig:
+ if self.secret_name is not None and self.secret_name == "":
+ raise MountConfigError(
+ message="modal cloud bucket secret_name must be a non-empty string",
+ context={"mount_type": mount.type},
+ )
+ if self.secret_environment_name is not None and self.secret_environment_name == "":
+ raise MountConfigError(
+ message="modal cloud bucket secret_environment_name must be a non-empty string",
+ context={"mount_type": mount.type},
+ )
+ if self.secret_environment_name is not None and self.secret_name is None:
+ raise MountConfigError(
+ message=(
+ "modal cloud bucket secret_environment_name requires secret_name to also be set"
+ ),
+ context={"mount_type": mount.type},
+ )
+
+ if isinstance(mount, S3Mount):
+ s3_credentials: dict[str, str] = {}
+ if mount.access_key_id is not None:
+ s3_credentials["AWS_ACCESS_KEY_ID"] = mount.access_key_id
+ if mount.secret_access_key is not None:
+ s3_credentials["AWS_SECRET_ACCESS_KEY"] = mount.secret_access_key
+ if mount.session_token is not None:
+ s3_credentials["AWS_SESSION_TOKEN"] = mount.session_token
+ if self.secret_name is not None and s3_credentials:
+ raise MountConfigError(
+ message=(
+ "modal cloud bucket mounts do not support both inline credentials "
+ "and secret_name"
+ ),
+ context={"mount_type": mount.type},
+ )
+ return ModalCloudBucketMountConfig(
+ bucket_name=mount.bucket,
+ bucket_endpoint_url=mount.endpoint_url,
+ key_prefix=mount.prefix,
+ credentials=s3_credentials or None,
+ secret_name=self.secret_name,
+ secret_environment_name=self.secret_environment_name,
+ read_only=mount.read_only,
+ )
+
+ if isinstance(mount, R2Mount):
+ mount._validate_credential_pair()
+ r2_credentials: dict[str, str] = {}
+ if mount.access_key_id is not None:
+ r2_credentials["AWS_ACCESS_KEY_ID"] = mount.access_key_id
+ if mount.secret_access_key is not None:
+ r2_credentials["AWS_SECRET_ACCESS_KEY"] = mount.secret_access_key
+ if self.secret_name is not None and r2_credentials:
+ raise MountConfigError(
+ message=(
+ "modal cloud bucket mounts do not support both inline credentials "
+ "and secret_name"
+ ),
+ context={"mount_type": mount.type},
+ )
+ return ModalCloudBucketMountConfig(
+ bucket_name=mount.bucket,
+ bucket_endpoint_url=(
+ mount.custom_domain or f"https://{mount.account_id}.r2.cloudflarestorage.com"
+ ),
+ credentials=r2_credentials or None,
+ secret_name=self.secret_name,
+ secret_environment_name=self.secret_environment_name,
+ read_only=mount.read_only,
+ )
+
+ if isinstance(mount, GCSMount):
+ if not mount._use_s3_compatible_rclone() and self.secret_name is None:
+ raise MountConfigError(
+ message=(
+ "gcs modal cloud bucket mounts require access_id and secret_access_key"
+ ),
+ context={"type": mount.type},
+ )
+ gcs_credentials: dict[str, str] | None = None
+ if mount._use_s3_compatible_rclone():
+ assert mount.access_id is not None
+ assert mount.secret_access_key is not None
+ gcs_credentials = {
+ "GOOGLE_ACCESS_KEY_ID": mount.access_id,
+ "GOOGLE_ACCESS_KEY_SECRET": mount.secret_access_key,
+ }
+ if self.secret_name is not None and gcs_credentials is not None:
+ raise MountConfigError(
+ message=(
+ "modal cloud bucket mounts do not support both inline credentials "
+ "and secret_name"
+ ),
+ context={"mount_type": mount.type},
+ )
+ return ModalCloudBucketMountConfig(
+ bucket_name=mount.bucket,
+ bucket_endpoint_url=mount.endpoint_url or "https://storage.googleapis.com",
+ key_prefix=mount.prefix,
+ credentials=gcs_credentials,
+ secret_name=self.secret_name,
+ secret_environment_name=self.secret_environment_name,
+ read_only=mount.read_only,
+ )
+
+ raise MountConfigError(
+ message="modal cloud bucket mounts are not supported for this mount type",
+ context={"mount_type": mount.type},
+ )
diff --git a/src/agents/extensions/sandbox/modal/sandbox.py b/src/agents/extensions/sandbox/modal/sandbox.py
new file mode 100644
index 00000000..27fd2f61
--- /dev/null
+++ b/src/agents/extensions/sandbox/modal/sandbox.py
@@ -0,0 +1,2009 @@
+"""
+Modal sandbox (https://modal.com) implementation.
+
+Run `python -m modal setup` to configure Modal locally.
+
+This module provides a Modal-backed sandbox client/session implementation backed by
+`modal.Sandbox`.
+
+Note: The `modal` dependency is intended to be optional (installed via an extra),
+so package-level exports should guard imports of this module. Within this module,
+we import Modal normally so IDEs can resolve and navigate Modal types.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import functools
+import io
+import json
+import logging
+import math
+import os
+import shlex
+import time
+import uuid
+from collections.abc import AsyncIterator, Awaitable, Callable
+from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal, TypeVar, cast
+
+import modal
+from modal.config import config as modal_config
+from modal.container_process import ContainerProcess
+
+from ....sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
+from ....sandbox.entries import Mount
+from ....sandbox.errors import (
+ ExecTimeoutError,
+ ExecTransportError,
+ ExposedPortUnavailableError,
+ MountConfigError,
+ WorkspaceArchiveReadError,
+ WorkspaceArchiveWriteError,
+ WorkspaceReadNotFoundError,
+ WorkspaceStartError,
+ WorkspaceStopError,
+ WorkspaceWriteTypeError,
+)
+from ....sandbox.manifest import Manifest
+from ....sandbox.session import SandboxSession, SandboxSessionState
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+from ....sandbox.session.dependencies import Dependencies
+from ....sandbox.session.manager import Instrumentation
+from ....sandbox.session.pty_types import (
+ PTY_PROCESSES_MAX,
+ PTY_PROCESSES_WARNING,
+ PtyExecUpdate,
+ allocate_pty_process_id,
+ clamp_pty_yield_time_ms,
+ process_id_to_prune_from_meta,
+ resolve_pty_write_yield_time_ms,
+ truncate_text_by_tokens,
+)
+from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
+from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
+from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
+from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
+from ....sandbox.util.retry import (
+ TRANSIENT_HTTP_STATUS_CODES,
+ exception_chain_contains_type,
+ exception_chain_has_status_code,
+ retry_async,
+)
+from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
+from .mounts import ModalCloudBucketMountStrategy
+
+_DEFAULT_TIMEOUT_S = 30.0
+_DEFAULT_IMAGE_TAG = DEFAULT_PYTHON_SANDBOX_IMAGE
+_DEFAULT_IMAGE_BUILDER_VERSION = "2025.06"
+_DEFAULT_SNAPSHOT_FILESYSTEM_TIMEOUT_S = 60.0
+_MODAL_STDIN_CHUNK_SIZE = 8 * 1024 * 1024
+_PTY_POLL_INTERVAL_S = 0.05
+
+WorkspacePersistenceMode = Literal["tar", "snapshot_filesystem", "snapshot_directory"]
+
+_WORKSPACE_PERSISTENCE_TAR: WorkspacePersistenceMode = "tar"
+_WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM: WorkspacePersistenceMode = "snapshot_filesystem"
+_WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY: WorkspacePersistenceMode = "snapshot_directory"
+
+# Magic prefixes for snapshot payloads that cannot be represented as tar bytes.
+_MODAL_SANDBOX_FS_SNAPSHOT_MAGIC = b"MODAL_SANDBOX_FS_SNAPSHOT_V1\n"
+_MODAL_SANDBOX_DIR_SNAPSHOT_MAGIC = b"MODAL_SANDBOX_DIR_SNAPSHOT_V1\n"
+
+logger = logging.getLogger(__name__)
+R = TypeVar("R")
+
+
+@asynccontextmanager
+async def _override_modal_image_builder_version(
+ image_builder_version: str | None,
+) -> AsyncIterator[None]:
+ """Apply a process-local Modal image builder version for the duration of a build."""
+
+ if image_builder_version is None:
+ yield
+ return
+
+ previous_value = os.environ.get("MODAL_IMAGE_BUILDER_VERSION")
+ modal_config.override_locally("image_builder_version", image_builder_version)
+ try:
+ yield
+ finally:
+ if previous_value is None:
+ os.environ.pop("MODAL_IMAGE_BUILDER_VERSION", None)
+ else:
+ os.environ["MODAL_IMAGE_BUILDER_VERSION"] = previous_value
+
+
+def _maybe_set_sandbox_cmd(
+ image: modal.Image,
+ *,
+ use_sleep_cmd: bool,
+) -> modal.Image:
+ if not use_sleep_cmd:
+ return image
+ return image.cmd(["sleep", "infinity"])
+
+
+async def _write_process_stdin(proc: ContainerProcess[bytes], data: bytes | bytearray) -> None:
+ """
+ Stream stdin to Modal in bounded chunks so command-router backed writers do not overflow.
+ """
+
+ view = memoryview(data)
+ for start in range(0, len(view), _MODAL_STDIN_CHUNK_SIZE):
+ proc.stdin.write(view[start : start + _MODAL_STDIN_CHUNK_SIZE])
+ await proc.stdin.drain.aio()
+ proc.stdin.write_eof()
+ await proc.stdin.drain.aio()
+
+
+class ModalSandboxClientOptions(BaseSandboxClientOptions):
+ type: Literal["modal"] = "modal"
+ app_name: str
+ sandbox_create_timeout_s: float | None = None
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR
+ snapshot_filesystem_timeout_s: float | None = None
+ snapshot_filesystem_restore_timeout_s: float | None = None
+ exposed_ports: tuple[int, ...] = ()
+ gpu: str | None = None # Modal GPU type, e.g. "A100" or "H100:8"
+ timeout: int = 300 # Lifetime of a sandbox from creation in seconds, defaults to 5 minutes
+ use_sleep_cmd: bool = True
+ image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION
+
+ def __init__(
+ self,
+ app_name: str,
+ sandbox_create_timeout_s: float | None = None,
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR,
+ snapshot_filesystem_timeout_s: float | None = None,
+ snapshot_filesystem_restore_timeout_s: float | None = None,
+ exposed_ports: tuple[int, ...] = (),
+ gpu: str | None = None,
+ timeout: int = 300, # 5 minutes
+ use_sleep_cmd: bool = True,
+ image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION,
+ *,
+ type: Literal["modal"] = "modal",
+ ) -> None:
+ super().__init__(
+ type=type,
+ app_name=app_name,
+ sandbox_create_timeout_s=sandbox_create_timeout_s,
+ workspace_persistence=workspace_persistence,
+ snapshot_filesystem_timeout_s=snapshot_filesystem_timeout_s,
+ snapshot_filesystem_restore_timeout_s=snapshot_filesystem_restore_timeout_s,
+ exposed_ports=exposed_ports,
+ gpu=gpu,
+ timeout=timeout,
+ use_sleep_cmd=use_sleep_cmd,
+ image_builder_version=image_builder_version,
+ )
+
+
+def _encode_modal_snapshot_ref(
+ *,
+ snapshot_id: str,
+ workspace_persistence: WorkspacePersistenceMode,
+) -> bytes:
+ # Small JSON envelope so we can round-trip a non-tar snapshot reference
+ # through Snapshot.persist().
+ body = json.dumps(
+ {"snapshot_id": snapshot_id, "workspace_persistence": workspace_persistence},
+ separators=(",", ":"),
+ sort_keys=True,
+ ).encode("utf-8")
+ if workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY:
+ return _MODAL_SANDBOX_DIR_SNAPSHOT_MAGIC + body
+ return _MODAL_SANDBOX_FS_SNAPSHOT_MAGIC + body
+
+
+def _encode_snapshot_filesystem_ref(*, snapshot_id: str) -> bytes:
+ return _encode_modal_snapshot_ref(
+ snapshot_id=snapshot_id,
+ workspace_persistence=_WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM,
+ )
+
+
+def _encode_snapshot_directory_ref(*, snapshot_id: str) -> bytes:
+ return _encode_modal_snapshot_ref(
+ snapshot_id=snapshot_id,
+ workspace_persistence=_WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY,
+ )
+
+
+def _decode_modal_snapshot_ref(raw: bytes) -> tuple[WorkspacePersistenceMode, str] | None:
+ if raw.startswith(_MODAL_SANDBOX_DIR_SNAPSHOT_MAGIC):
+ prefix = _MODAL_SANDBOX_DIR_SNAPSHOT_MAGIC
+ default_persistence = _WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY
+ elif raw.startswith(_MODAL_SANDBOX_FS_SNAPSHOT_MAGIC):
+ prefix = _MODAL_SANDBOX_FS_SNAPSHOT_MAGIC
+ default_persistence = _WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM
+ else:
+ return None
+ body = raw[len(prefix) :]
+ try:
+ obj = json.loads(body.decode("utf-8"))
+ except Exception:
+ return None
+ snapshot_id = obj.get("snapshot_id")
+ workspace_persistence = obj.get("workspace_persistence", default_persistence)
+ if workspace_persistence not in (
+ _WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM,
+ _WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY,
+ ):
+ return None
+ if not isinstance(snapshot_id, str) or not snapshot_id:
+ return None
+ return cast(WorkspacePersistenceMode, workspace_persistence), snapshot_id
+
+
+@dataclass(frozen=True)
+class ModalImageSelector:
+ """
+ A single "image selector" type to avoid juggling image/image_id/image_tag separately.
+ """
+
+ kind: Literal["image", "id", "tag"]
+ value: modal.Image | str
+
+ @classmethod
+ def from_image(cls, image: modal.Image) -> ModalImageSelector:
+ return cls(kind="image", value=image)
+
+ @classmethod
+ def from_id(cls, image_id: str) -> ModalImageSelector:
+ return cls(kind="id", value=image_id)
+
+ @classmethod
+ def from_tag(cls, image_tag: str) -> ModalImageSelector:
+ return cls(kind="tag", value=image_tag)
+
+
+@dataclass(frozen=True)
+class ModalSandboxSelector:
+ """
+ A single "sandbox selector" type to avoid juggling sandbox/sandbox_id separately.
+ """
+
+ kind: Literal["sandbox", "id"]
+ value: modal.Sandbox | str
+
+ @classmethod
+ def from_sandbox(cls, sandbox: modal.Sandbox) -> ModalSandboxSelector:
+ return cls(kind="sandbox", value=sandbox)
+
+ @classmethod
+ def from_id(cls, sandbox_id: str) -> ModalSandboxSelector:
+ return cls(kind="id", value=sandbox_id)
+
+
+class ModalSandboxSessionState(SandboxSessionState):
+ """
+ Serializable state for a Modal-backed session.
+
+ We store only values that can be safely persisted and later used by `resume()`.
+ """
+
+ type: Literal["modal"] = "modal"
+ app_name: str
+ # Optional Modal image object id (enables reconstructing a custom image via Image.from_id()).
+ image_id: str | None = None
+ # Registry image tag (e.g. "debian:bookworm" or "ghcr.io/org/img:tag").
+ # Used when `image_id` isn't available and no in-memory image override was provided.
+ image_tag: str | None = None
+ # Timeout for creating a sandbox (Modal calls are synchronous from the user's perspective
+ # and can block; we wrap them in a thread with asyncio timeout).
+ sandbox_create_timeout_s: float = _DEFAULT_TIMEOUT_S
+ sandbox_id: str | None = None
+ # Workspace persistence mode:
+ # - "tar": create a tar stream in the sandbox via `tar cf - ...` and pull bytes back via stdout.
+ # - "snapshot_filesystem": use Modal's `Sandbox.snapshot_filesystem()`
+ # (if available) and persist a snapshot reference.
+ # - "snapshot_directory": use Modal's `Sandbox.snapshot_directory()` on the workspace root
+ # and reattach it during resume.
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR
+ # Async timeouts for snapshot_filesystem-based persistence and restore.
+ snapshot_filesystem_timeout_s: float = _DEFAULT_SNAPSHOT_FILESYSTEM_TIMEOUT_S
+ snapshot_filesystem_restore_timeout_s: float = _DEFAULT_SNAPSHOT_FILESYSTEM_TIMEOUT_S
+ gpu: str | None = None # Modal GPU type, e.g. "A100" or "H100:8"
+ # Maximum lifetime of the sandbox in seconds
+ timeout: int = 300 # 5 minutes
+ use_sleep_cmd: bool = True
+ image_builder_version: str | None = _DEFAULT_IMAGE_BUILDER_VERSION
+
+
+@dataclass
+class _ModalPtyProcessEntry:
+ process: ContainerProcess[bytes]
+ tty: bool
+ last_used: float = field(default_factory=time.monotonic)
+ stdout_iter: AsyncIterator[object] | None = None
+ stderr_iter: AsyncIterator[object] | None = None
+ stdout_read_task: asyncio.Task[object] | None = None
+ stderr_read_task: asyncio.Task[object] | None = None
+
+
+class ModalSandboxSession(BaseSandboxSession):
+ """
+ SandboxSession implementation backed by a Modal Sandbox.
+ """
+
+ state: ModalSandboxSessionState
+
+ _sandbox: modal.Sandbox | None
+ _image: modal.Image | None
+ _running: bool
+ _pty_lock: asyncio.Lock
+ _pty_processes: dict[int, _ModalPtyProcessEntry]
+ _reserved_pty_process_ids: set[int]
+ _modal_snapshot_ephemeral_backup: bytes | None
+ _modal_snapshot_ephemeral_backup_path: Path | None
+
+ def __init__(
+ self,
+ *,
+ state: ModalSandboxSessionState,
+ # Optional in-memory handles. These are not guaranteed to be resumable; state holds ids.
+ image: modal.Image | None = None,
+ sandbox: modal.Sandbox | None = None,
+ ) -> None:
+ self.state = state
+ self._image = None
+ if image is not None:
+ self._image = _maybe_set_sandbox_cmd(
+ image,
+ use_sleep_cmd=self.state.use_sleep_cmd,
+ )
+ self._sandbox = sandbox
+ if self._image is not None:
+ self.state.image_id = getattr(self._image, "object_id", self.state.image_id)
+ if sandbox is not None:
+ self.state.sandbox_id = getattr(sandbox, "object_id", self.state.sandbox_id)
+ self._running = False
+ self._pty_lock = asyncio.Lock()
+ self._pty_processes = {}
+ self._reserved_pty_process_ids = set()
+ self._modal_snapshot_ephemeral_backup = None
+ self._modal_snapshot_ephemeral_backup_path = None
+
+ async def _normalize_path_for_io(self, path: Path | str) -> Path:
+ return await self._normalize_path_for_remote_io(path)
+
+ def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
+ return (RESOLVE_WORKSPACE_PATH_HELPER,)
+
+ def _current_runtime_helper_cache_key(self) -> object | None:
+ return self.state.sandbox_id
+
+ @classmethod
+ def from_state(
+ cls,
+ state: ModalSandboxSessionState,
+ *,
+ image: modal.Image | None = None,
+ sandbox: modal.Sandbox | None = None,
+ ) -> ModalSandboxSession:
+ return cls(state=state, image=image, sandbox=sandbox)
+
+ async def _call_modal(
+ self,
+ fn: Callable[..., R],
+ *args: object,
+ call_timeout: float | None = None,
+ **kwargs: object,
+ ) -> R:
+ """
+ Prefer Modal's async interface (`fn.aio(...)`) when available.
+
+ Falls back to running the blocking call in a thread to preserve compatibility
+ with SDK surfaces that do not expose `.aio`.
+ """
+
+ aio_fn = getattr(fn, "aio", None)
+ if callable(aio_fn):
+ coro = cast(Awaitable[R], aio_fn(*args, **kwargs))
+ else:
+ loop = asyncio.get_running_loop()
+ bound = functools.partial(fn, *args, **kwargs)
+ coro = loop.run_in_executor(None, bound)
+ if call_timeout is None:
+ return await coro
+ return await asyncio.wait_for(coro, timeout=call_timeout)
+
+ async def _ensure_backend_started(self) -> None:
+ await self._ensure_sandbox()
+
+ async def _prepare_backend_workspace(self) -> None:
+ # Ensure workspace root exists before the base workspace flow needs it.
+ await self.exec("mkdir", "-p", "--", str(Path(self.state.manifest.root)), shell=False)
+
+ async def _after_start(self) -> None:
+ self._running = True
+
+ async def _after_start_failed(self) -> None:
+ self._running = False
+
+ def _wrap_start_error(self, error: Exception) -> Exception:
+ if isinstance(error, WorkspaceStartError):
+ return error
+ return WorkspaceStartError(path=Path(self.state.manifest.root), cause=error)
+
+ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+
+ try:
+ tunnels = await asyncio.wait_for(self._sandbox.tunnels.aio(), timeout=10.0)
+ except Exception as e:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "modal", "detail": "tunnels_lookup_failed"},
+ cause=e,
+ ) from e
+
+ if not isinstance(tunnels, dict):
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "modal", "detail": "invalid_tunnels_response"},
+ )
+
+ tunnel = tunnels.get(port)
+ host = getattr(tunnel, "host", None)
+ host_port = getattr(tunnel, "port", None)
+ if not isinstance(host, str) or not host or not isinstance(host_port, int):
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "modal", "detail": "port_not_exposed"},
+ )
+ return ExposedPortEndpoint(host=host, port=host_port, tls=True)
+
+ def _wrap_stop_error(self, error: Exception) -> Exception:
+ if isinstance(error, WorkspaceStopError):
+ return error
+ return WorkspaceStopError(path=Path(self.state.manifest.root), cause=error)
+
+ async def _shutdown_backend(self) -> None:
+ try:
+ sandbox = self._sandbox
+ if sandbox is not None:
+ await self._call_modal(
+ sandbox.terminate,
+ call_timeout=_DEFAULT_TIMEOUT_S,
+ )
+ elif self.state.sandbox_id:
+ sid = self.state.sandbox_id
+ assert sid is not None
+ sb = await self._call_modal(
+ modal.Sandbox.from_id,
+ sid,
+ call_timeout=_DEFAULT_TIMEOUT_S,
+ )
+ await self._call_modal(
+ sb.terminate,
+ call_timeout=_DEFAULT_TIMEOUT_S,
+ )
+ except Exception:
+ pass
+ finally:
+ self.state.sandbox_id = None
+ self.state.workspace_root_ready = False
+ self._sandbox = None
+ self._running = False
+
+ async def _ensure_sandbox(self) -> bool:
+ if self._sandbox is not None:
+ return False
+
+ # If resuming, try to rehydrate the sandbox handle from the persisted id.
+ sid = self.state.sandbox_id
+ if sid:
+ try:
+ sb = await self._call_modal(
+ modal.Sandbox.from_id,
+ sid,
+ call_timeout=self.state.sandbox_create_timeout_s,
+ )
+
+ # `poll()` returns an exit code when the sandbox is terminated, else None.
+ poll_result = await self._call_modal(sb.poll, call_timeout=_DEFAULT_TIMEOUT_S)
+ is_running = poll_result is None
+ if is_running:
+ self._sandbox = sb
+ self._running = True
+ return True
+ except Exception:
+ pass
+
+ # Resumed sandbox handle is dead or invalid; clear and create a fresh one.
+ self._sandbox = None
+ self.state.sandbox_id = None
+
+ app = await self._call_modal(
+ modal.App.lookup,
+ self.state.app_name,
+ create_if_missing=True,
+ call_timeout=10.0,
+ )
+ if not self._image:
+ image_id = self.state.image_id
+ if image_id:
+ self._image = modal.Image.from_id(image_id)
+ else:
+ tag = self.state.image_tag
+ if not isinstance(tag, str) or not tag:
+ tag = _DEFAULT_IMAGE_TAG
+ # Record the default for better debuggability/resume.
+ self.state.image_tag = tag
+ self._image = await self._call_modal(
+ modal.Image.from_registry,
+ tag,
+ call_timeout=_DEFAULT_TIMEOUT_S,
+ )
+ self._image = _maybe_set_sandbox_cmd(
+ self._image,
+ use_sleep_cmd=self.state.use_sleep_cmd,
+ )
+
+ manifest_envs = cast(dict[str, str | None], await self.state.manifest.environment.resolve())
+ volumes = self._modal_cloud_bucket_mounts_for_manifest()
+ create_coro = modal.Sandbox.create.aio(
+ app=app,
+ image=self._image,
+ workdir=self.state.manifest.root,
+ env=manifest_envs,
+ encrypted_ports=self.state.exposed_ports,
+ volumes=volumes,
+ gpu=self.state.gpu,
+ timeout=self.state.timeout,
+ )
+ async with _override_modal_image_builder_version(self.state.image_builder_version):
+ if self.state.sandbox_create_timeout_s is None:
+ self._sandbox = await create_coro
+ else:
+ self._sandbox = await asyncio.wait_for(
+ create_coro, timeout=self.state.sandbox_create_timeout_s
+ )
+
+ # Persist sandbox id for future resume.
+ assert self._sandbox is not None
+ self.state.sandbox_id = self._sandbox.object_id
+ self.state.workspace_root_ready = False
+
+ assert self._image is not None
+ self.state.image_id = self._image.object_id
+ return False
+
+ async def snapshot_filesystem(self) -> str:
+ """Snapshot the current sandbox filesystem and return the resulting Modal image ID.
+
+ The returned ID can be passed as ``image_id`` when creating a new sandbox to boot
+ from this filesystem state. The image ID is also stored in ``state.image_id`` for future
+ resume.
+ """
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+ snap_coro = self._sandbox.snapshot_filesystem.aio()
+ if self.state.snapshot_filesystem_timeout_s is None:
+ snap = await snap_coro
+ else:
+ snap = await asyncio.wait_for(
+ snap_coro, timeout=self.state.snapshot_filesystem_timeout_s
+ )
+ image_id: str | None
+ if isinstance(snap, str):
+ image_id = snap
+ else:
+ image_id = getattr(snap, "object_id", None) or getattr(snap, "id", None)
+ if not isinstance(image_id, str) or not image_id:
+ raise RuntimeError(
+ f"snapshot_filesystem returned unexpected type: {type(snap).__name__}"
+ )
+ self.state.image_id = image_id
+ self._image = modal.Image.from_id(image_id)
+ return image_id
+
+ async def _exec_internal(
+ self, *command: str | Path, timeout: float | None = None
+ ) -> ExecResult:
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+
+ modal_timeout: int | None = None
+ if timeout is not None:
+ # Modal's Sandbox.exec timeout is integer seconds; use ceil so the command
+ # is guaranteed to be terminated server-side at or before our timeout window
+ # (modulo 1s granularity).
+ modal_timeout = int(max(_DEFAULT_TIMEOUT_S, math.ceil(timeout)))
+
+ async def _run_async() -> ExecResult:
+ assert self._sandbox is not None
+ argv: tuple[str, ...] = tuple(str(part) for part in command)
+ proc = await self._sandbox.exec.aio(*argv, text=False, timeout=modal_timeout)
+ # Drain full output; Modal buffers process output server-side.
+ stdout = await proc.stdout.read.aio()
+ stderr = await proc.stderr.read.aio()
+ exit_code = await proc.wait.aio()
+ return ExecResult(stdout=stdout or b"", stderr=stderr or b"", exit_code=exit_code or 0)
+
+ try:
+ run_coro = _run_async()
+ if timeout is None:
+ return await run_coro
+ return await asyncio.wait_for(run_coro, timeout=timeout)
+ except asyncio.TimeoutError as e:
+ sandbox = self._sandbox
+ if sandbox is not None:
+ try:
+ await self._call_modal(sandbox.terminate, call_timeout=_DEFAULT_TIMEOUT_S)
+ except Exception:
+ pass
+ self._sandbox = None
+ self.state.sandbox_id = None
+ self._running = False
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ except ExecTimeoutError:
+ raise
+ except Exception as e:
+ raise ExecTransportError(command=command, cause=e) from e
+
+ def supports_pty(self) -> bool:
+ return True
+
+ 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:
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+
+ sanitized_command = self._prepare_exec_command(*command, shell=shell, user=user)
+ argv: tuple[str, ...] = tuple(str(part) for part in sanitized_command)
+ modal_timeout: int | None = None
+ if timeout is not None:
+ modal_timeout = int(max(_DEFAULT_TIMEOUT_S, math.ceil(timeout)))
+
+ entry: _ModalPtyProcessEntry | None = None
+ registered = False
+ pruned_entry: _ModalPtyProcessEntry | None = None
+ process_id = 0
+ process_count = 0
+ try:
+ process = cast(
+ Any,
+ await self._call_modal(
+ self._sandbox.exec,
+ *argv,
+ text=False,
+ timeout=modal_timeout,
+ pty=tty,
+ ),
+ )
+ entry = _ModalPtyProcessEntry(process=process, tty=tty)
+
+ async with self._pty_lock:
+ process_id = allocate_pty_process_id(self._reserved_pty_process_ids)
+ self._reserved_pty_process_ids.add(process_id)
+ pruned_entry = await self._prune_pty_processes_if_needed()
+ self._pty_processes[process_id] = entry
+ registered = True
+ process_count = len(self._pty_processes)
+ except asyncio.TimeoutError as e:
+ if entry is not None and not registered:
+ await self._terminate_pty_entry(entry)
+ raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
+ except asyncio.CancelledError:
+ if entry is not None and not registered:
+ await self._terminate_pty_entry(entry)
+ raise
+ except Exception as e:
+ if entry is not None and not registered:
+ await self._terminate_pty_entry(entry)
+ raise ExecTransportError(command=command, cause=e) from e
+
+ if pruned_entry is not None:
+ await self._terminate_pty_entry(pruned_entry)
+
+ if process_count >= PTY_PROCESSES_WARNING:
+ logger.warning(
+ "PTY process count reached warning threshold: %s active sessions",
+ process_count,
+ )
+
+ yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms),
+ max_output_tokens=max_output_tokens,
+ )
+ return await self._finalize_pty_update(
+ process_id=process_id,
+ entry=entry,
+ output=output,
+ original_token_count=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:
+ async with self._pty_lock:
+ entry = self._resolve_pty_session_entry(
+ pty_processes=self._pty_processes,
+ session_id=session_id,
+ )
+
+ if chars:
+ if not entry.tty:
+ raise RuntimeError("stdin is not available for this process")
+ await self._write_pty_stdin(entry.process, chars.encode("utf-8"))
+ await asyncio.sleep(0.1)
+
+ yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000)
+ output, original_token_count = await self._collect_pty_output(
+ entry=entry,
+ yield_time_ms=resolve_pty_write_yield_time_ms(
+ yield_time_ms=yield_time_ms, input_empty=chars == ""
+ ),
+ max_output_tokens=max_output_tokens,
+ )
+ entry.last_used = time.monotonic()
+ return await self._finalize_pty_update(
+ process_id=session_id,
+ entry=entry,
+ output=output,
+ original_token_count=original_token_count,
+ )
+
+ async def pty_terminate_all(self) -> None:
+ async with self._pty_lock:
+ entries = list(self._pty_processes.values())
+ self._pty_processes.clear()
+ self._reserved_pty_process_ids.clear()
+
+ for entry in entries:
+ await self._terminate_pty_entry(entry)
+
+ async def _write_pty_stdin(self, process: ContainerProcess[bytes], payload: bytes) -> None:
+ stdin = process.stdin
+ write = getattr(stdin, "write", None)
+ if not callable(write):
+ raise RuntimeError("stdin is not writable for this process")
+ await self._call_modal(write, payload, call_timeout=5.0)
+
+ drain = getattr(stdin, "drain", None)
+ if callable(drain):
+ await self._call_modal(drain, call_timeout=5.0)
+
+ async def _collect_pty_output(
+ self,
+ *,
+ entry: _ModalPtyProcessEntry,
+ yield_time_ms: int,
+ max_output_tokens: int | None,
+ ) -> tuple[bytes, int | None]:
+ deadline = time.monotonic() + (yield_time_ms / 1000)
+ chunks = bytearray()
+
+ while True:
+ stdout_chunk = await self._read_modal_stream(entry=entry, stream_name="stdout")
+ stderr_chunk = await self._read_modal_stream(entry=entry, stream_name="stderr")
+ if stdout_chunk:
+ chunks.extend(stdout_chunk)
+ if stderr_chunk:
+ chunks.extend(stderr_chunk)
+
+ if time.monotonic() >= deadline:
+ break
+
+ exit_code = await self._peek_exit_code(entry.process)
+ if exit_code is not None:
+ stdout_chunks = await self._drain_modal_stream(entry=entry, stream_name="stdout")
+ stderr_chunks = await self._drain_modal_stream(entry=entry, stream_name="stderr")
+ chunks.extend(stdout_chunks)
+ chunks.extend(stderr_chunks)
+ break
+
+ if not stdout_chunk and not stderr_chunk:
+ remaining_s = deadline - time.monotonic()
+ if remaining_s <= 0:
+ break
+ await asyncio.sleep(min(_PTY_POLL_INTERVAL_S, remaining_s))
+
+ text = chunks.decode("utf-8", errors="replace")
+ truncated_text, original_token_count = truncate_text_by_tokens(text, max_output_tokens)
+ return truncated_text.encode("utf-8", errors="replace"), original_token_count
+
+ async def _drain_modal_stream(
+ self,
+ *,
+ entry: _ModalPtyProcessEntry,
+ stream_name: Literal["stdout", "stderr"],
+ ) -> bytes:
+ chunks = bytearray()
+ while True:
+ chunk = await self._read_modal_stream(
+ entry=entry,
+ stream_name=stream_name,
+ await_pending=True,
+ )
+ if not chunk:
+ break
+ chunks.extend(chunk)
+ return bytes(chunks)
+
+ async def _read_modal_stream(
+ self,
+ *,
+ entry: _ModalPtyProcessEntry,
+ stream_name: Literal["stdout", "stderr"],
+ await_pending: bool = False,
+ ) -> bytes:
+ stream = entry.process.stdout if stream_name == "stdout" else entry.process.stderr
+ if stream is None:
+ return b""
+
+ iter_attr = "stdout_iter" if stream_name == "stdout" else "stderr_iter"
+ task_attr = "stdout_read_task" if stream_name == "stdout" else "stderr_read_task"
+ stream_iter = getattr(entry, iter_attr)
+ if stream_iter is None:
+ aiter_method = getattr(stream, "__aiter__", None)
+ if callable(aiter_method):
+ try:
+ stream_iter = aiter_method()
+ except Exception:
+ stream_iter = None
+ else:
+ setattr(entry, iter_attr, stream_iter)
+
+ task = getattr(entry, task_attr)
+ if task is None and stream_iter is not None:
+ task = asyncio.create_task(stream_iter.__anext__())
+ setattr(entry, task_attr, task)
+
+ if task is not None:
+ wait_timeout = 0.2 if await_pending else 0
+ done, _pending = await asyncio.wait({task}, timeout=wait_timeout)
+ if not done:
+ return b""
+
+ setattr(entry, task_attr, None)
+ try:
+ value = task.result()
+ except StopAsyncIteration:
+ setattr(entry, iter_attr, None)
+ return b""
+ except Exception:
+ setattr(entry, iter_attr, None)
+ return b""
+
+ return self._coerce_modal_stream_chunk(value)
+
+ read = getattr(stream, "read", None)
+ if not callable(read):
+ return b""
+
+ try:
+ value = await self._call_modal(read, 16_384, call_timeout=0.2)
+ except TypeError:
+ return b""
+ except Exception:
+ return b""
+
+ return self._coerce_modal_stream_chunk(value)
+
+ def _coerce_modal_stream_chunk(self, value: object) -> bytes:
+ if value is None:
+ return b""
+ if isinstance(value, bytes):
+ return value
+ if isinstance(value, bytearray):
+ return bytes(value)
+ if isinstance(value, str):
+ return value.encode("utf-8", errors="replace")
+ return str(value).encode("utf-8", errors="replace")
+
+ async def _finalize_pty_update(
+ self,
+ *,
+ process_id: int,
+ entry: _ModalPtyProcessEntry,
+ output: bytes,
+ original_token_count: int | None,
+ ) -> PtyExecUpdate:
+ exit_code = await self._peek_exit_code(entry.process)
+ live_process_id: int | None = process_id
+ if exit_code is not None:
+ async with self._pty_lock:
+ removed = self._pty_processes.pop(process_id, None)
+ self._reserved_pty_process_ids.discard(process_id)
+ if removed is not None:
+ await self._terminate_pty_entry(removed)
+ live_process_id = None
+
+ return PtyExecUpdate(
+ process_id=live_process_id,
+ output=output,
+ exit_code=exit_code,
+ original_token_count=original_token_count,
+ )
+
+ async def _prune_pty_processes_if_needed(self) -> _ModalPtyProcessEntry | None:
+ if len(self._pty_processes) < PTY_PROCESSES_MAX:
+ return None
+
+ meta: list[tuple[int, float, bool]] = []
+ for process_id, entry in self._pty_processes.items():
+ exit_code = await self._peek_exit_code(entry.process)
+ meta.append((process_id, entry.last_used, exit_code is not None))
+ process_id_to_prune = process_id_to_prune_from_meta(meta)
+ if process_id_to_prune is None:
+ return None
+
+ self._reserved_pty_process_ids.discard(process_id_to_prune)
+ return self._pty_processes.pop(process_id_to_prune, None)
+
+ async def _peek_exit_code(self, process: ContainerProcess[bytes]) -> int | None:
+ try:
+ value = await self._call_modal(process.poll, call_timeout=0.2)
+ except Exception:
+ return None
+
+ if value is None:
+ return None
+ if isinstance(value, int):
+ return value
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+ async def _terminate_pty_entry(self, entry: _ModalPtyProcessEntry) -> None:
+ process = entry.process
+ for task in (entry.stdout_read_task, entry.stderr_read_task):
+ if task is not None and not task.done():
+ task.cancel()
+
+ try:
+ terminated = False
+ terminate = getattr(process, "terminate", None)
+ if callable(terminate):
+ await self._call_modal(terminate, call_timeout=5.0)
+ terminated = True
+
+ if not terminated:
+ stdin = getattr(process, "stdin", None)
+ else:
+ stdin = None
+ if stdin is not None:
+ write_eof = getattr(stdin, "write_eof", None)
+ if callable(write_eof):
+ await self._call_modal(write_eof, call_timeout=5.0)
+ except Exception:
+ pass
+ finally:
+ await asyncio.gather(
+ *(
+ task
+ for task in (entry.stdout_read_task, entry.stderr_read_task)
+ if task is not None
+ ),
+ return_exceptions=True,
+ )
+
+ async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase:
+ if user is not None:
+ await self._check_read_with_exec(path, user=user)
+
+ # Read by `cat` so the payload is returned as bytes.
+ workspace_path = await self._normalize_path_for_io(path)
+ cmd = ["sh", "-lc", f"cat -- {shlex.quote(str(workspace_path))}"]
+ try:
+ out = await self.exec(*cmd, shell=False)
+ except ExecTimeoutError as e:
+ raise WorkspaceArchiveReadError(path=workspace_path, cause=e) from e
+ except ExecTransportError as e:
+ raise WorkspaceArchiveReadError(path=workspace_path, cause=e) from e
+
+ if not out.ok():
+ raise WorkspaceReadNotFoundError(
+ path=path, context={"stderr": out.stderr.decode("utf-8", "replace")}
+ )
+
+ return io.BytesIO(out.stdout)
+
+ async def write(
+ self,
+ path: Path,
+ data: io.IOBase,
+ *,
+ user: str | User | None = None,
+ ) -> None:
+ if user is not None:
+ await self._check_write_with_exec(path, user=user)
+
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
+
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+
+ workspace_path = await self._normalize_path_for_io(path)
+
+ async def _run_write() -> None:
+ assert self._sandbox is not None
+ # Ensure parent directory exists.
+ parent = str(workspace_path.parent)
+ mkdir_proc = await self._sandbox.exec.aio("mkdir", "-p", "--", parent, text=False)
+ await mkdir_proc.wait.aio()
+
+ # Stream bytes into `cat > file` to avoid quoting/binary issues.
+ cmd = ["sh", "-lc", f"cat > {shlex.quote(str(workspace_path))}"]
+ proc = await self._sandbox.exec.aio(*cmd, text=False)
+ await _write_process_stdin(proc, payload)
+ exit_code = await proc.wait.aio()
+ if exit_code != 0:
+ stderr = await proc.stderr.read.aio()
+ raise WorkspaceArchiveWriteError(
+ path=workspace_path,
+ context={
+ "reason": "write_nonzero_exit",
+ "exit_code": exit_code,
+ "stderr": stderr.decode("utf-8", "replace"),
+ },
+ )
+
+ try:
+ await asyncio.wait_for(_run_write(), timeout=30.0)
+ except WorkspaceArchiveWriteError:
+ raise
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
+
+ async def running(self) -> bool:
+ if not self._running or self._sandbox is None:
+ return False
+
+ try:
+ assert self._sandbox is not None
+ poll_result = await asyncio.wait_for(self._sandbox.poll.aio(), timeout=5.0)
+ return poll_result is None
+ except Exception:
+ return False
+
+ async def persist_workspace(self) -> io.IOBase:
+ if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM:
+ return await self._persist_workspace_via_snapshot_filesystem()
+ if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY:
+ return await self._persist_workspace_via_snapshot_directory()
+ return await self._persist_workspace_via_tar()
+
+ async def hydrate_workspace(self, data: io.IOBase) -> None:
+ if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM:
+ return await self._hydrate_workspace_via_snapshot_filesystem(data)
+ if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY:
+ return await self._hydrate_workspace_via_snapshot_directory(data)
+ return await self._hydrate_workspace_via_tar(data)
+
+ async def _persist_workspace_via_snapshot_filesystem(self) -> io.IOBase:
+ """
+ Persist the workspace using Modal's snapshot_filesystem API when available.
+
+ Modal's snapshot_filesystem is expected to return a snapshot reference
+ (a Modal Image handle). We serialize a small reference envelope that
+ `_hydrate_workspace_via_snapshot_filesystem` can interpret.
+ """
+
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+ if not hasattr(self._sandbox, "snapshot_filesystem"):
+ return await self._persist_workspace_via_tar()
+ if self._native_snapshot_requires_tar_fallback():
+ return await self._persist_workspace_via_tar()
+ root = Path(self.state.manifest.root)
+ plain_skip = self._modal_snapshot_plain_skip_relpaths(root)
+ skip_abs = [root / rel for rel in sorted(plain_skip, key=lambda p: p.as_posix())]
+ self._modal_snapshot_ephemeral_backup = None
+ self._modal_snapshot_ephemeral_backup_path = None
+
+ async def restore_ephemeral_paths() -> WorkspaceArchiveReadError | None:
+ backup = self._modal_snapshot_ephemeral_backup
+ if not backup:
+ return None
+
+ try:
+ assert self._sandbox is not None
+ proc = await self._sandbox.exec.aio("tar", "xf", "-", "-C", str(root), text=False)
+ await _write_process_stdin(proc, bytes(backup))
+ exit_code = await proc.wait.aio()
+ if exit_code != 0:
+ stderr = await proc.stderr.read.aio()
+ return WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "snapshot_filesystem_ephemeral_restore_failed",
+ "exit_code": exit_code,
+ "stderr": stderr.decode("utf-8", "replace"),
+ },
+ )
+ except Exception as exc:
+ if isinstance(exc, WorkspaceArchiveReadError):
+ return exc
+ return WorkspaceArchiveReadError(
+ path=root,
+ context={"reason": "snapshot_filesystem_ephemeral_restore_failed"},
+ cause=exc,
+ )
+ return None
+
+ if skip_abs:
+ rel_args = " ".join(shlex.quote(p.relative_to(root).as_posix()) for p in skip_abs)
+ cmd = f"cd -- {shlex.quote(str(root))} && (tar cf - -- {rel_args} 2>/dev/null || true)"
+ out = await self.exec("sh", "-lc", cmd, shell=False)
+ self._modal_snapshot_ephemeral_backup = out.stdout or b""
+
+ rm_cmd = ["rm", "-rf", "--", *[str(p) for p in skip_abs]]
+ rm_out = await self.exec(*rm_cmd, shell=False)
+ if not rm_out.ok():
+ cleanup_restore_error = await restore_ephemeral_paths()
+ if cleanup_restore_error is not None:
+ logger.warning(
+ "Failed to restore Modal ephemeral paths after cleanup failure: %s",
+ cleanup_restore_error,
+ )
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "snapshot_filesystem_ephemeral_remove_failed",
+ "exit_code": rm_out.exit_code,
+ "stderr": rm_out.stderr.decode("utf-8", "replace"),
+ },
+ )
+
+ try:
+ snapshot_sandbox = await self._refresh_sandbox_handle_for_snapshot()
+ snap_coro = snapshot_sandbox.snapshot_filesystem.aio()
+ if self.state.snapshot_filesystem_timeout_s is None:
+ snap = await snap_coro
+ else:
+ snap = await asyncio.wait_for(
+ snap_coro, timeout=self.state.snapshot_filesystem_timeout_s
+ )
+ except Exception as e:
+ restore_error = await restore_ephemeral_paths()
+ if restore_error is not None:
+ logger.warning(
+ "Failed to restore Modal ephemeral paths after snapshot failure: %s",
+ restore_error,
+ )
+ raise WorkspaceArchiveReadError(
+ path=root, context={"reason": "snapshot_filesystem_failed"}, cause=e
+ ) from e
+
+ snapshot_id, snapshot_error = self._extract_modal_snapshot_id(
+ snap=snap, root=root, snapshot_kind="snapshot_filesystem"
+ )
+
+ restore_error = await restore_ephemeral_paths()
+ if restore_error is not None:
+ raise restore_error
+
+ if snapshot_error is not None:
+ raise snapshot_error
+
+ assert snapshot_id is not None
+ return io.BytesIO(_encode_snapshot_filesystem_ref(snapshot_id=snapshot_id))
+
+ async def _persist_workspace_via_snapshot_directory(self) -> io.IOBase:
+ """
+ Persist the workspace using Modal's snapshot_directory API when available.
+ """
+
+ root = Path(self.state.manifest.root)
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+ if not hasattr(self._sandbox, "snapshot_directory"):
+ return await self._persist_workspace_via_tar()
+ if self._native_snapshot_requires_tar_fallback():
+ return await self._persist_workspace_via_tar()
+ plain_skip = self._modal_snapshot_plain_skip_relpaths(root)
+ skip_abs = [root / rel for rel in sorted(plain_skip, key=lambda p: p.as_posix())]
+ self._modal_snapshot_ephemeral_backup = None
+ self._modal_snapshot_ephemeral_backup_path = None
+ detached_mounts: list[tuple[Mount, Path]] = []
+
+ async def restore_ephemeral_paths() -> WorkspaceArchiveReadError | None:
+ backup_path = self._modal_snapshot_ephemeral_backup_path
+ if backup_path is None:
+ return None
+
+ restore_cmd = (
+ f"if [ ! -f {shlex.quote(str(backup_path))} ]; then "
+ f"echo missing ephemeral backup archive >&2; "
+ f"exit 1; "
+ f"fi; "
+ f"tar xf {shlex.quote(str(backup_path))} -C {shlex.quote(str(root))} && "
+ f"rm -f -- {shlex.quote(str(backup_path))}"
+ )
+ out = await self.exec("sh", "-lc", restore_cmd, shell=False)
+ if not out.ok():
+ return WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "snapshot_directory_ephemeral_restore_failed",
+ "exit_code": out.exit_code,
+ "stderr": out.stderr.decode("utf-8", "replace"),
+ },
+ )
+ return None
+
+ async def restore_detached_mounts() -> WorkspaceArchiveReadError | None:
+ remount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in reversed(detached_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry,
+ self,
+ mount_path,
+ )
+ except Exception as e:
+ current_error = WorkspaceArchiveReadError(path=root, cause=e)
+ if remount_error is None:
+ remount_error = current_error
+ else:
+ additional_remount_errors = remount_error.context.setdefault(
+ "additional_remount_errors", []
+ )
+ assert isinstance(additional_remount_errors, list)
+ additional_remount_errors.append(
+ {
+ "message": current_error.message,
+ "cause_type": type(e).__name__,
+ "cause": str(e),
+ }
+ )
+ return remount_error
+
+ snapshot_error: WorkspaceArchiveReadError | None = None
+ snapshot_id: str | None = None
+ try:
+ if skip_abs:
+ backup_path = (
+ Path("/tmp/openai-agents/session-state")
+ / self.state.session_id.hex
+ / "modal-snapshot-directory-ephemeral.tar"
+ )
+ rel_args = " ".join(shlex.quote(p.relative_to(root).as_posix()) for p in skip_abs)
+ backup_cmd = (
+ f"mkdir -p -- {shlex.quote(str(backup_path.parent))} && "
+ f"cd -- {shlex.quote(str(root))} && "
+ "{ "
+ f"for rel in {rel_args}; do "
+ 'if [ -e "$rel" ]; then printf \'%s\\n\' "$rel"; fi; '
+ "done; "
+ "} | "
+ f"tar cf {shlex.quote(str(backup_path))} -T - 2>/dev/null && "
+ f"test -f {shlex.quote(str(backup_path))}"
+ )
+ backup_out = await self.exec("sh", "-lc", backup_cmd, shell=False)
+ if not backup_out.ok():
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "snapshot_directory_ephemeral_backup_failed",
+ "exit_code": backup_out.exit_code,
+ "stderr": backup_out.stderr.decode("utf-8", "replace"),
+ },
+ )
+ self._modal_snapshot_ephemeral_backup_path = backup_path
+
+ rm_cmd = ["rm", "-rf", "--", *[str(p) for p in skip_abs]]
+ rm_out = await self.exec(*rm_cmd, shell=False)
+ if not rm_out.ok():
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "snapshot_directory_ephemeral_remove_failed",
+ "exit_code": rm_out.exit_code,
+ "stderr": rm_out.stderr.decode("utf-8", "replace"),
+ },
+ )
+
+ for mount_entry, mount_path in self._snapshot_directory_mount_targets_to_restore(root):
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry,
+ self,
+ mount_path,
+ )
+ detached_mounts.append((mount_entry, mount_path))
+
+ snapshot_sandbox = await self._refresh_sandbox_handle_for_snapshot()
+ snap_coro = snapshot_sandbox.snapshot_directory.aio(str(root))
+ if self.state.snapshot_filesystem_timeout_s is None:
+ snap = await snap_coro
+ else:
+ snap = await asyncio.wait_for(
+ snap_coro, timeout=self.state.snapshot_filesystem_timeout_s
+ )
+ snapshot_id, snapshot_error = self._extract_modal_snapshot_id(
+ snap=snap, root=root, snapshot_kind="snapshot_directory"
+ )
+ except WorkspaceArchiveReadError as e:
+ snapshot_error = e
+ except Exception as e:
+ snapshot_error = WorkspaceArchiveReadError(
+ path=root, context={"reason": "snapshot_directory_failed"}, cause=e
+ )
+ finally:
+ remount_error = await restore_detached_mounts()
+ restore_error = await restore_ephemeral_paths()
+ cleanup_error = remount_error
+ if restore_error is not None:
+ if cleanup_error is None:
+ cleanup_error = restore_error
+ else:
+ additional_restore_errors = cleanup_error.context.setdefault(
+ "additional_restore_errors", []
+ )
+ assert isinstance(additional_restore_errors, list)
+ additional_restore_errors.append(
+ {
+ "message": restore_error.message,
+ "cause_type": (
+ type(restore_error.cause).__name__
+ if restore_error.cause is not None
+ else None
+ ),
+ "cause": str(restore_error.cause) if restore_error.cause else None,
+ }
+ )
+
+ if cleanup_error is not None:
+ if snapshot_error is not None:
+ cleanup_error.context["snapshot_error_before_restore_corruption"] = {
+ "message": snapshot_error.message
+ }
+ raise cleanup_error
+
+ if snapshot_error is not None:
+ raise snapshot_error
+
+ assert snapshot_id is not None
+ return io.BytesIO(_encode_snapshot_directory_ref(snapshot_id=snapshot_id))
+
+ def _extract_modal_snapshot_id(
+ self,
+ *,
+ snap: object,
+ root: Path,
+ snapshot_kind: Literal["snapshot_filesystem", "snapshot_directory"],
+ ) -> tuple[str | None, WorkspaceArchiveReadError | None]:
+ if isinstance(snap, bytes | bytearray):
+ return None, WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": f"{snapshot_kind}_unexpected_bytes",
+ "type": type(snap).__name__,
+ },
+ )
+ if not hasattr(snap, "object_id") and not isinstance(snap, str):
+ return None, WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": f"{snapshot_kind}_unexpected_return",
+ "type": type(snap).__name__,
+ },
+ )
+ if isinstance(snap, str):
+ return snap, None
+ snapshot_id = getattr(snap, "object_id", None)
+ if snapshot_id is not None and not isinstance(snapshot_id, str):
+ snapshot_id = None
+ if not snapshot_id:
+ return None, WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": f"{snapshot_kind}_unexpected_return",
+ "type": type(snap).__name__,
+ },
+ )
+ return snapshot_id, None
+
+ async def _refresh_sandbox_handle_for_snapshot(self) -> modal.Sandbox:
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+
+ sandbox_module = type(self._sandbox).__module__
+ if not sandbox_module.startswith("modal"):
+ return self._sandbox
+
+ sandbox_id = self.state.sandbox_id or getattr(self._sandbox, "object_id", None)
+ if not sandbox_id:
+ return self._sandbox
+
+ try:
+ refreshed = await self._call_modal(
+ modal.Sandbox.from_id,
+ sandbox_id,
+ call_timeout=_DEFAULT_TIMEOUT_S,
+ )
+ except Exception:
+ return self._sandbox
+
+ self._sandbox = refreshed
+ return refreshed
+
+ def _modal_snapshot_plain_skip_relpaths(self, root: Path) -> set[Path]:
+ plain_skip = set(self.state.manifest.ephemeral_entry_paths())
+ if self._runtime_persist_workspace_skip_relpaths:
+ plain_skip.update(self._runtime_persist_workspace_skip_relpaths)
+
+ mount_skip_rel_paths: set[Path] = set()
+ for rel_path, artifact in self.state.manifest.iter_entries():
+ if isinstance(artifact, Mount) and artifact.ephemeral:
+ mount_skip_rel_paths.add(rel_path)
+ for _mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ mount_skip_rel_paths.add(mount_path.relative_to(root))
+ except ValueError:
+ continue
+ return plain_skip - mount_skip_rel_paths
+
+ def _modal_tar_skip_relpaths(self, root: Path) -> set[Path]:
+ """Return Modal tar-capture skip paths, including resolved mount targets."""
+
+ skip = self._persist_workspace_skip_relpaths()
+ for _mount_entry, mount_path in self.state.manifest.mount_targets():
+ try:
+ skip.add(mount_path.relative_to(root))
+ except ValueError:
+ continue
+ return skip
+
+ @retry_async(
+ retry_if=lambda exc, self: (
+ exception_chain_contains_type(exc, (ExecTransportError,))
+ or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES)
+ )
+ )
+ async def _persist_workspace_via_tar(self) -> io.IOBase:
+ # Existing tar implementation extracted so snapshot_filesystem mode can fall back cleanly.
+ root = Path(self.state.manifest.root)
+ skip = self._modal_tar_skip_relpaths(root)
+
+ excludes: list[str] = []
+ for rel in sorted(skip, key=lambda p: p.as_posix()):
+ excludes.extend(["--exclude", f"./{rel.as_posix().lstrip('./')}"])
+
+ cmd: list[str] = [
+ "tar",
+ "cf",
+ "-",
+ *excludes,
+ "-C",
+ str(root),
+ ".",
+ ]
+
+ try:
+ out = await self.exec(*cmd, shell=False)
+ if not out.ok():
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "tar_nonzero_exit",
+ "exit_code": out.exit_code,
+ "stderr": out.stderr.decode("utf-8", "replace"),
+ },
+ )
+ return io.BytesIO(out.stdout)
+ except WorkspaceArchiveReadError:
+ raise
+ except Exception as e:
+ raise WorkspaceArchiveReadError(path=root, cause=e) from e
+
+ async def _hydrate_workspace_via_snapshot_filesystem(self, data: io.IOBase) -> None:
+ """
+ Hydrate using Modal's snapshot_filesystem restore API when the
+ persisted payload is a snapshot ref. Otherwise, fall back to tar
+ extraction (to support SDKs that return tar bytes).
+ """
+ root = Path(self.state.manifest.root)
+ raw, snapshot_id = self._read_modal_snapshot_id_from_archive(
+ data=data.read(),
+ expected_persistence=_WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM,
+ invalid_reason="snapshot_filesystem_invalid_snapshot_id",
+ )
+ if snapshot_id is None:
+ return await self._hydrate_workspace_via_tar(io.BytesIO(raw))
+ await self._restore_snapshot_filesystem_image(snapshot_id=snapshot_id, root=root)
+
+ async def _hydrate_workspace_via_snapshot_directory(self, data: io.IOBase) -> None:
+ """
+ Hydrate using Modal's snapshot_directory restore API when the
+ persisted payload is a snapshot ref. Otherwise, fall back to tar extraction.
+ """
+
+ root = Path(self.state.manifest.root)
+ raw, snapshot_id = self._read_modal_snapshot_id_from_archive(
+ data=data.read(),
+ expected_persistence=_WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY,
+ invalid_reason="snapshot_directory_invalid_snapshot_id",
+ )
+ if snapshot_id is None:
+ return await self._hydrate_workspace_via_tar(io.BytesIO(raw))
+ await self._restore_snapshot_directory_image(snapshot_id=snapshot_id, root=root)
+
+ def _read_modal_snapshot_id_from_archive(
+ self,
+ *,
+ data: object,
+ expected_persistence: WorkspacePersistenceMode,
+ invalid_reason: str,
+ ) -> tuple[bytes, str | None]:
+ root = Path(self.state.manifest.root)
+ raw = data
+ if isinstance(raw, str):
+ raw = raw.encode("utf-8")
+ if not isinstance(raw, bytes | bytearray):
+ raise WorkspaceArchiveWriteError(path=root, context={"reason": "non_bytes_payload"})
+ raw_bytes = bytes(raw)
+
+ snapshot_ref = _decode_modal_snapshot_ref(raw_bytes)
+ if snapshot_ref is None:
+ return raw_bytes, None
+ workspace_persistence, snapshot_id = snapshot_ref
+ if workspace_persistence != expected_persistence:
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={"reason": invalid_reason, "workspace_persistence": workspace_persistence},
+ )
+ if not snapshot_id:
+ raise WorkspaceArchiveWriteError(path=root, context={"reason": invalid_reason})
+ return raw_bytes, snapshot_id
+
+ async def _restore_snapshot_filesystem_image(self, *, snapshot_id: str, root: Path) -> None:
+ prior = self._sandbox
+ if prior is not None:
+ try:
+ await self._call_modal(prior.terminate, call_timeout=_DEFAULT_TIMEOUT_S)
+ except Exception:
+ pass
+ finally:
+ self._sandbox = None
+ self.state.sandbox_id = None
+
+ manifest_envs = cast(dict[str, str | None], await self.state.manifest.environment.resolve())
+
+ async def _run_restore() -> None:
+ image = modal.Image.from_id(snapshot_id)
+ app = await modal.App.lookup.aio(self.state.app_name, create_if_missing=True)
+ sb = await modal.Sandbox.create.aio(
+ app=app,
+ image=image,
+ workdir=self.state.manifest.root,
+ env=manifest_envs,
+ encrypted_ports=self.state.exposed_ports,
+ volumes=self._modal_cloud_bucket_mounts_for_manifest(),
+ gpu=self.state.gpu,
+ timeout=self.state.timeout,
+ )
+ try:
+ mkdir_proc = await sb.exec.aio("mkdir", "-p", "--", str(root), text=False)
+ await mkdir_proc.wait.aio()
+ except Exception:
+ pass
+ self._image = image
+ self.state.image_id = snapshot_id
+ self._sandbox = sb
+ self.state.sandbox_id = sb.object_id
+
+ try:
+ await asyncio.wait_for(
+ _run_restore(), timeout=self.state.snapshot_filesystem_restore_timeout_s
+ )
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "snapshot_filesystem_restore_failed",
+ "snapshot_id": snapshot_id,
+ },
+ cause=e,
+ ) from e
+
+ async def _restore_snapshot_directory_image(self, *, snapshot_id: str, root: Path) -> None:
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+ sandbox = self._sandbox
+
+ async def _run_restore() -> None:
+ image = modal.Image.from_id(snapshot_id)
+ await self._call_modal(
+ sandbox.mount_image,
+ str(root),
+ image,
+ call_timeout=self.state.snapshot_filesystem_restore_timeout_s,
+ )
+ for mount_entry, mount_path in reversed(
+ self._snapshot_directory_mount_targets_to_restore(root)
+ ):
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry,
+ self,
+ mount_path,
+ )
+
+ try:
+ await asyncio.wait_for(
+ _run_restore(), timeout=self.state.snapshot_filesystem_restore_timeout_s
+ )
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "snapshot_directory_restore_failed",
+ "snapshot_id": snapshot_id,
+ },
+ cause=e,
+ ) from e
+
+ def _snapshot_directory_mount_targets_to_restore(self, root: Path) -> list[tuple[Mount, Path]]:
+ mount_targets: list[tuple[Mount, Path]] = []
+ for mount_entry, mount_path in self.state.manifest.mount_targets():
+ if mount_entry.ephemeral:
+ continue
+ if isinstance(mount_entry.mount_strategy, ModalCloudBucketMountStrategy):
+ continue
+ if mount_path != root and root not in mount_path.parents:
+ continue
+ mount_targets.append((mount_entry, mount_path))
+ return mount_targets
+
+ async def _hydrate_workspace_via_tar(self, data: io.IOBase) -> None:
+ root = Path(self.state.manifest.root)
+
+ raw = data.read()
+ if isinstance(raw, str):
+ raw = raw.encode("utf-8")
+ if not isinstance(raw, bytes | bytearray):
+ raise WorkspaceArchiveWriteError(path=root, context={"reason": "non_bytes_tar_payload"})
+
+ try:
+ validate_tar_bytes(
+ bytes(raw),
+ skip_rel_paths=self.state.manifest.ephemeral_persistence_paths(),
+ )
+ except UnsafeTarMemberError as e:
+ raise WorkspaceArchiveWriteError(
+ path=root, context={"reason": e.reason, "member": e.member}, cause=e
+ ) from e
+
+ await self._ensure_sandbox()
+ assert self._sandbox is not None
+
+ async def _run_extract() -> None:
+ assert self._sandbox is not None
+ mkdir_proc = await self._sandbox.exec.aio("mkdir", "-p", "--", str(root), text=False)
+ await mkdir_proc.wait.aio()
+ proc = await self._sandbox.exec.aio("tar", "xf", "-", "-C", str(root), text=False)
+ await _write_process_stdin(proc, raw)
+ exit_code = await proc.wait.aio()
+ if exit_code != 0:
+ stderr = await proc.stderr.read.aio()
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "tar_extract_nonzero_exit",
+ "exit_code": exit_code,
+ "stderr": stderr.decode("utf-8", "replace"),
+ },
+ )
+
+ try:
+ await asyncio.wait_for(_run_extract(), timeout=60.0)
+ except WorkspaceArchiveWriteError:
+ raise
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=root, cause=e) from e
+
+ def _modal_cloud_bucket_mounts_for_manifest(
+ self,
+ ) -> dict[str | os.PathLike[Any], modal.Volume | modal.CloudBucketMount]:
+ volumes: dict[str | os.PathLike[Any], modal.Volume | modal.CloudBucketMount] = {}
+ for mount_entry, mount_path in self.state.manifest.mount_targets():
+ strategy = mount_entry.mount_strategy
+ if not isinstance(strategy, ModalCloudBucketMountStrategy):
+ continue
+ config = strategy._build_modal_cloud_bucket_mount_config(mount_entry)
+ secret = None
+ if config.secret_name is not None:
+ secret = modal.Secret.from_name(
+ config.secret_name,
+ environment_name=config.secret_environment_name,
+ )
+ elif config.credentials is not None:
+ secret = modal.Secret.from_dict(cast(dict[str, str | None], config.credentials))
+ volumes[mount_path.as_posix()] = modal.CloudBucketMount(
+ bucket_name=config.bucket_name,
+ bucket_endpoint_url=config.bucket_endpoint_url,
+ key_prefix=config.key_prefix,
+ secret=secret,
+ read_only=config.read_only,
+ )
+ return volumes
+
+
+class ModalSandboxClient(BaseSandboxClient[ModalSandboxClientOptions]):
+ backend_id = "modal"
+ _default_image: ModalImageSelector | None
+ _default_sandbox: ModalSandboxSelector | None
+ _instrumentation: Instrumentation
+
+ def __init__(
+ self,
+ *,
+ image: ModalImageSelector | None = None,
+ sandbox: ModalSandboxSelector | None = None,
+ instrumentation: Instrumentation | None = None,
+ dependencies: Dependencies | None = None,
+ ) -> None:
+ self._default_image = image
+ self._default_sandbox = sandbox
+ self._instrumentation = instrumentation or Instrumentation()
+ self._dependencies = dependencies
+
+ def _validate_manifest_for_workspace_persistence(
+ self,
+ *,
+ manifest: Manifest,
+ workspace_persistence: WorkspacePersistenceMode,
+ ) -> None:
+ if workspace_persistence != _WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY:
+ return
+
+ root = Path(manifest.root)
+ for mount_entry, mount_path in manifest.mount_targets():
+ if not isinstance(mount_entry.mount_strategy, ModalCloudBucketMountStrategy):
+ continue
+ if mount_path == root or root in mount_path.parents:
+ raise MountConfigError(
+ message=(
+ "snapshot_directory is not supported when a Modal cloud bucket mount "
+ "lives at or under the workspace root"
+ ),
+ context={
+ "workspace_root": str(root),
+ "mount_path": str(mount_path),
+ "workspace_persistence": workspace_persistence,
+ },
+ )
+
+ async def create(
+ self,
+ *,
+ snapshot: SnapshotSpec | SnapshotBase | None = None,
+ manifest: Manifest | None = None,
+ options: ModalSandboxClientOptions,
+ ) -> SandboxSession:
+ """
+ Create a new Modal-backed session.
+
+ Expected options:
+ - app_name: str (required)
+ - sandbox_create_timeout_s: float | None (async timeout for sandbox creation call)
+ - workspace_persistence: Literal["tar", "snapshot_filesystem", "snapshot_directory"]
+ (optional)
+ - snapshot_filesystem_timeout_s: float | None
+ (async timeout for snapshot_filesystem call)
+ - snapshot_filesystem_restore_timeout_s: float | None
+ (async timeout for snapshot restore call)
+ - timeout: int (maximum sandbox lifetime in seconds, default 300)
+ - image_builder_version: str | None (Modal image builder version, default "2025.06")
+ """
+
+ if options is None:
+ raise ValueError("ModalSandboxClient.create requires options with app_name")
+ manifest = manifest or Manifest()
+ app_name = options.app_name
+ if not app_name:
+ raise ValueError("ModalSandboxClient.create requires a valid app_name")
+
+ image_sel = self._default_image
+
+ sandbox_sel = self._default_sandbox
+
+ sandbox_create_timeout_s = options.sandbox_create_timeout_s
+ if sandbox_create_timeout_s is not None and not isinstance(
+ sandbox_create_timeout_s, int | float
+ ):
+ raise ValueError(
+ "ModalSandboxClient.create requires sandbox_create_timeout_s to be a number"
+ )
+
+ workspace_persistence = options.workspace_persistence
+ if workspace_persistence not in (
+ _WORKSPACE_PERSISTENCE_TAR,
+ _WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM,
+ _WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY,
+ ):
+ raise ValueError(
+ "ModalSandboxClient.create requires workspace_persistence to be one of "
+ f"{_WORKSPACE_PERSISTENCE_TAR!r}, "
+ f"{_WORKSPACE_PERSISTENCE_SNAPSHOT_FILESYSTEM!r}, or "
+ f"{_WORKSPACE_PERSISTENCE_SNAPSHOT_DIRECTORY!r}"
+ )
+ snapshot_filesystem_timeout_s = options.snapshot_filesystem_timeout_s
+ if snapshot_filesystem_timeout_s is not None and not isinstance(
+ snapshot_filesystem_timeout_s, int | float
+ ):
+ raise ValueError(
+ "ModalSandboxClient.create requires snapshot_filesystem_timeout_s to be a number"
+ )
+
+ snapshot_filesystem_restore_timeout_s = options.snapshot_filesystem_restore_timeout_s
+ if snapshot_filesystem_restore_timeout_s is not None and not isinstance(
+ snapshot_filesystem_restore_timeout_s, int | float
+ ):
+ raise ValueError(
+ "ModalSandboxClient.create requires "
+ "snapshot_filesystem_restore_timeout_s to be a number"
+ )
+ image_builder_version = options.image_builder_version
+ if "image_builder_version" not in options.model_fields_set or image_builder_version == "":
+ image_builder_version = _DEFAULT_IMAGE_BUILDER_VERSION
+ elif image_builder_version is not None and not isinstance(image_builder_version, str):
+ raise ValueError(
+ "ModalSandboxClient.create requires image_builder_version to be a string or None"
+ )
+
+ self._validate_manifest_for_workspace_persistence(
+ manifest=manifest,
+ workspace_persistence=workspace_persistence,
+ )
+
+ session_id = uuid.uuid4()
+ state_image_id: str | None = None
+ state_image_tag: str | None = None
+ session_image: modal.Image | None = None
+ if image_sel is not None:
+ if image_sel.kind == "image":
+ if not isinstance(image_sel.value, modal.Image):
+ raise ValueError(
+ "ModalSandboxClient.__init__ requires image to be a modal.Image"
+ )
+ session_image = image_sel.value
+ state_image_id = getattr(session_image, "object_id", None)
+ elif image_sel.kind == "id":
+ if not isinstance(image_sel.value, str) or not image_sel.value:
+ raise ValueError(
+ "ModalSandboxClient.__init__ requires image_id to be a non-empty string"
+ )
+ state_image_id = image_sel.value
+ else:
+ if not isinstance(image_sel.value, str) or not image_sel.value:
+ raise ValueError(
+ "ModalSandboxClient.__init__ requires image_tag to be a non-empty string"
+ )
+ state_image_tag = image_sel.value
+
+ state_sandbox_id: str | None = None
+ session_sandbox: modal.Sandbox | None = None
+ if sandbox_sel is not None:
+ if sandbox_sel.kind == "sandbox":
+ if not isinstance(sandbox_sel.value, modal.Sandbox):
+ raise ValueError(
+ "ModalSandboxClient.__init__ requires sandbox to be a modal.Sandbox"
+ )
+ session_sandbox = sandbox_sel.value
+ state_sandbox_id = getattr(session_sandbox, "object_id", None)
+ else:
+ if not isinstance(sandbox_sel.value, str) or not sandbox_sel.value:
+ raise ValueError(
+ "ModalSandboxClient.__init__ requires sandbox_id to be a non-empty string"
+ )
+ state_sandbox_id = sandbox_sel.value
+
+ snapshot_id = str(session_id)
+ snapshot_instance = resolve_snapshot(snapshot, snapshot_id)
+ state = ModalSandboxSessionState(
+ session_id=session_id,
+ manifest=manifest,
+ snapshot=snapshot_instance,
+ app_name=app_name,
+ image_tag=state_image_tag,
+ image_id=state_image_id,
+ sandbox_id=state_sandbox_id,
+ workspace_persistence=workspace_persistence,
+ exposed_ports=options.exposed_ports,
+ gpu=options.gpu,
+ timeout=options.timeout,
+ use_sleep_cmd=options.use_sleep_cmd,
+ image_builder_version=image_builder_version,
+ )
+ if sandbox_create_timeout_s is not None:
+ state.sandbox_create_timeout_s = float(sandbox_create_timeout_s)
+ if snapshot_filesystem_timeout_s is not None:
+ state.snapshot_filesystem_timeout_s = float(snapshot_filesystem_timeout_s)
+ if snapshot_filesystem_restore_timeout_s is not None:
+ state.snapshot_filesystem_restore_timeout_s = float(
+ snapshot_filesystem_restore_timeout_s
+ )
+
+ # Pass the in-memory handles through to the session (they may not be resumable).
+ inner = ModalSandboxSession.from_state(
+ state,
+ image=session_image,
+ sandbox=session_sandbox,
+ )
+ await inner._ensure_sandbox()
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ async def delete(self, session: SandboxSession) -> SandboxSession:
+ """
+ Best-effort cleanup of Modal sandbox resources.
+ """
+
+ inner = session._inner
+ if not isinstance(inner, ModalSandboxSession):
+ raise TypeError("ModalSandboxClient.delete expects a ModalSandboxSession")
+
+ # Prefer the live handle if present.
+ sandbox = getattr(inner, "_sandbox", None)
+ try:
+ if sandbox is not None:
+ await inner._call_modal(sandbox.terminate, call_timeout=_DEFAULT_TIMEOUT_S)
+ return session
+ except Exception:
+ return session
+
+ # Otherwise, best-effort terminate via sandbox_id.
+ sid = inner.state.sandbox_id
+ if sid:
+ try:
+ sb = await inner._call_modal(
+ modal.Sandbox.from_id,
+ sid,
+ call_timeout=_DEFAULT_TIMEOUT_S,
+ )
+ await inner._call_modal(sb.terminate, call_timeout=_DEFAULT_TIMEOUT_S)
+ except Exception:
+ pass
+
+ return session
+
+ async def resume(
+ self,
+ state: SandboxSessionState,
+ ) -> SandboxSession:
+ if not isinstance(state, ModalSandboxSessionState):
+ raise TypeError("ModalSandboxClient.resume expects a ModalSandboxSessionState")
+ inner = ModalSandboxSession.from_state(state)
+ reconnected = await inner._ensure_sandbox()
+ if reconnected:
+ inner._set_start_state_preserved(True)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
+ return ModalSandboxSessionState.model_validate(payload)
diff --git a/src/agents/extensions/sandbox/runloop/__init__.py b/src/agents/extensions/sandbox/runloop/__init__.py
new file mode 100644
index 00000000..afc228d4
--- /dev/null
+++ b/src/agents/extensions/sandbox/runloop/__init__.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from .mounts import RunloopCloudBucketMountStrategy
+from .sandbox import (
+ DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT,
+ DEFAULT_RUNLOOP_WORKSPACE_ROOT,
+ RunloopAfterIdle,
+ RunloopGatewaySpec,
+ RunloopLaunchParameters,
+ RunloopMcpSpec,
+ RunloopPlatformAxonsClient,
+ RunloopPlatformBenchmarksClient,
+ RunloopPlatformBlueprintsClient,
+ RunloopPlatformClient,
+ RunloopPlatformNetworkPoliciesClient,
+ RunloopPlatformSecretsClient,
+ RunloopSandboxClient,
+ RunloopSandboxClientOptions,
+ RunloopSandboxSession,
+ RunloopSandboxSessionState,
+ RunloopTimeouts,
+ RunloopTunnelConfig,
+ RunloopUserParameters,
+ _decode_runloop_snapshot_ref,
+ _encode_runloop_snapshot_ref,
+)
+
+__all__ = [
+ "DEFAULT_RUNLOOP_WORKSPACE_ROOT",
+ "DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT",
+ "RunloopAfterIdle",
+ "RunloopGatewaySpec",
+ "RunloopLaunchParameters",
+ "RunloopMcpSpec",
+ "RunloopPlatformAxonsClient",
+ "RunloopPlatformBenchmarksClient",
+ "RunloopPlatformBlueprintsClient",
+ "RunloopPlatformClient",
+ "RunloopPlatformNetworkPoliciesClient",
+ "RunloopPlatformSecretsClient",
+ "RunloopCloudBucketMountStrategy",
+ "RunloopSandboxClient",
+ "RunloopSandboxClientOptions",
+ "RunloopSandboxSession",
+ "RunloopSandboxSessionState",
+ "RunloopTimeouts",
+ "RunloopTunnelConfig",
+ "RunloopUserParameters",
+ "_decode_runloop_snapshot_ref",
+ "_encode_runloop_snapshot_ref",
+]
diff --git a/src/agents/extensions/sandbox/runloop/mounts.py b/src/agents/extensions/sandbox/runloop/mounts.py
new file mode 100644
index 00000000..d55fa048
--- /dev/null
+++ b/src/agents/extensions/sandbox/runloop/mounts.py
@@ -0,0 +1,245 @@
+"""Mount strategy for Runloop sandboxes."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Literal
+
+from ....sandbox.entries.mounts.base import InContainerMountStrategy, Mount, MountStrategyBase
+from ....sandbox.entries.mounts.patterns import RcloneMountPattern
+from ....sandbox.errors import MountConfigError
+from ....sandbox.materialization import MaterializedFile
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+
+_APT = "DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get -o Dpkg::Use-Pty=0"
+_RCLONE_CHECK = "command -v rclone >/dev/null 2>&1 || test -x /usr/local/bin/rclone"
+_INSTALL_RCLONE_COMMANDS = (
+ f"{_APT} update -qq",
+ f"{_APT} install -y -qq curl unzip ca-certificates",
+ "curl -fsSL https://rclone.org/install.sh | bash",
+)
+_INSTALL_FUSE_COMMANDS = (
+ f"{_APT} update -qq",
+ f"{_APT} install -y -qq fuse3",
+)
+_FUSE_ALLOW_OTHER = (
+ "chmod a+rw /dev/fuse && "
+ "touch /etc/fuse.conf && "
+ "(grep -qxF user_allow_other /etc/fuse.conf || "
+ "printf '\\nuser_allow_other\\n' >> /etc/fuse.conf)"
+)
+
+
+async def _ensure_fuse_support(session: BaseSandboxSession) -> None:
+ dev_fuse = await session.exec("sh", "-lc", "test -c /dev/fuse", shell=False)
+ if not dev_fuse.ok():
+ raise MountConfigError(
+ message="Runloop cloud bucket mounts require FUSE support",
+ context={"missing": "/dev/fuse"},
+ )
+
+ kmod = await session.exec("sh", "-lc", "grep -qw fuse /proc/filesystems", shell=False)
+ if not kmod.ok():
+ raise MountConfigError(
+ message="Runloop cloud bucket mounts require FUSE support",
+ context={"missing": "fuse in /proc/filesystems"},
+ )
+
+ fusermount = await session.exec(
+ "sh",
+ "-lc",
+ "command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1",
+ shell=False,
+ )
+ if not fusermount.ok():
+ apt = await session.exec("sh", "-lc", "command -v apt-get >/dev/null 2>&1", shell=False)
+ if not apt.ok():
+ raise MountConfigError(
+ message="fusermount is not installed and apt-get is unavailable; preinstall fuse3",
+ context={"package": "fuse3"},
+ )
+ for command in _INSTALL_FUSE_COMMANDS:
+ install = await session.exec(
+ "sh",
+ "-lc",
+ command,
+ shell=False,
+ timeout=300,
+ user="root",
+ )
+ if not install.ok():
+ raise MountConfigError(
+ message="failed to install fuse3",
+ context={"package": "fuse3", "exit_code": install.exit_code},
+ )
+
+ fusermount = await session.exec(
+ "sh",
+ "-lc",
+ "command -v fusermount3 >/dev/null 2>&1 || command -v fusermount >/dev/null 2>&1",
+ shell=False,
+ )
+ if not fusermount.ok():
+ raise MountConfigError(
+ message="fuse3 was installed but fusermount is still not available",
+ context={"package": "fuse3"},
+ )
+
+ chmod_result = await session.exec(
+ "sh",
+ "-lc",
+ _FUSE_ALLOW_OTHER,
+ shell=False,
+ timeout=30,
+ user="root",
+ )
+ if not chmod_result.ok():
+ raise MountConfigError(
+ message="failed to make /dev/fuse accessible",
+ context={"exit_code": chmod_result.exit_code},
+ )
+
+
+async def _ensure_rclone(session: BaseSandboxSession) -> None:
+ rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False)
+ if rclone.ok():
+ return
+
+ apt = await session.exec("sh", "-lc", "command -v apt-get >/dev/null 2>&1", shell=False)
+ if not apt.ok():
+ raise MountConfigError(
+ message="rclone is not installed and apt-get is unavailable; preinstall rclone",
+ context={"package": "rclone"},
+ )
+
+ for command in _INSTALL_RCLONE_COMMANDS:
+ install = await session.exec("sh", "-lc", command, shell=False, timeout=300, user="root")
+ if not install.ok():
+ raise MountConfigError(
+ message="failed to install rclone",
+ context={"package": "rclone", "exit_code": install.exit_code},
+ )
+
+ rclone = await session.exec("sh", "-lc", _RCLONE_CHECK, shell=False)
+ if not rclone.ok():
+ raise MountConfigError(
+ message="rclone was installed but is still not available on PATH",
+ context={"package": "rclone"},
+ )
+
+
+async def _default_user_ids(session: BaseSandboxSession) -> tuple[str, str] | None:
+ result = await session.exec("sh", "-lc", "id -u; id -g", shell=False, timeout=30)
+ if not result.ok():
+ return None
+
+ lines = result.stdout.decode("utf-8", errors="replace").splitlines()
+ if len(lines) < 2 or not lines[0].isdigit() or not lines[1].isdigit():
+ return None
+ return lines[0], lines[1]
+
+
+def _append_option(args: list[str], option: str, *values: str) -> None:
+ if option not in args:
+ args.extend([option, *values])
+
+
+async def _rclone_pattern_for_session(
+ session: BaseSandboxSession,
+ pattern: RcloneMountPattern,
+) -> RcloneMountPattern:
+ if pattern.mode != "fuse":
+ return pattern
+
+ extra_args = list(pattern.extra_args)
+ _append_option(extra_args, "--allow-other")
+ user_ids = await _default_user_ids(session)
+ if user_ids is not None:
+ uid, gid = user_ids
+ _append_option(extra_args, "--uid", uid)
+ _append_option(extra_args, "--gid", gid)
+
+ return pattern.model_copy(update={"extra_args": extra_args})
+
+
+def _assert_runloop_session(session: BaseSandboxSession) -> None:
+ if type(session).__name__ != "RunloopSandboxSession":
+ raise MountConfigError(
+ message="runloop cloud bucket mounts require a RunloopSandboxSession",
+ context={"session_type": type(session).__name__},
+ )
+
+
+class RunloopCloudBucketMountStrategy(MountStrategyBase):
+ """Mount cloud buckets in Runloop sandboxes via rclone."""
+
+ type: Literal["runloop_cloud_bucket"] = "runloop_cloud_bucket"
+ pattern: RcloneMountPattern = RcloneMountPattern(mode="fuse")
+
+ def _delegate(self) -> InContainerMountStrategy:
+ return InContainerMountStrategy(pattern=self.pattern)
+
+ async def _delegate_for_session(self, session: BaseSandboxSession) -> InContainerMountStrategy:
+ return InContainerMountStrategy(
+ pattern=await _rclone_pattern_for_session(session, self.pattern)
+ )
+
+ def validate_mount(self, mount: Mount) -> None:
+ self._delegate().validate_mount(mount)
+
+ async def activate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> list[MaterializedFile]:
+ _assert_runloop_session(session)
+ if self.pattern.mode == "fuse":
+ await _ensure_fuse_support(session)
+ await _ensure_rclone(session)
+ delegate = await self._delegate_for_session(session)
+ return await delegate.activate(mount, session, dest, base_dir)
+
+ async def deactivate(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ dest: Path,
+ base_dir: Path,
+ ) -> None:
+ _assert_runloop_session(session)
+ await self._delegate().deactivate(mount, session, dest, base_dir)
+
+ async def teardown_for_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_runloop_session(session)
+ await self._delegate().teardown_for_snapshot(mount, session, path)
+
+ async def restore_after_snapshot(
+ self,
+ mount: Mount,
+ session: BaseSandboxSession,
+ path: Path,
+ ) -> None:
+ _assert_runloop_session(session)
+ if self.pattern.mode == "fuse":
+ await _ensure_fuse_support(session)
+ await _ensure_rclone(session)
+ delegate = await self._delegate_for_session(session)
+ await delegate.restore_after_snapshot(mount, session, path)
+
+ def build_docker_volume_driver_config(
+ self,
+ mount: Mount,
+ ) -> tuple[str, dict[str, str], bool] | None:
+ return None
+
+
+__all__ = [
+ "RunloopCloudBucketMountStrategy",
+]
diff --git a/src/agents/extensions/sandbox/runloop/sandbox.py b/src/agents/extensions/sandbox/runloop/sandbox.py
new file mode 100644
index 00000000..29c9d48e
--- /dev/null
+++ b/src/agents/extensions/sandbox/runloop/sandbox.py
@@ -0,0 +1,1653 @@
+"""
+Runloop sandbox (https://runloop.ai) implementation.
+
+This module provides a Runloop-backed sandbox client/session implementation backed by
+`runloop_api_client.sdk.AsyncRunloopSDK`.
+
+The `runloop_api_client` dependency is optional, so package-level exports should guard imports of
+this module. Within this module, Runloop SDK imports are lazy so users without the extra can still
+import the package.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import io
+import json
+import logging
+import os
+import shlex
+import uuid
+from collections.abc import Sequence
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+from typing import TYPE_CHECKING, Any, Literal, cast
+from urllib.parse import urlsplit
+
+from pydantic import BaseModel, Field
+from runloop_api_client.types import (
+ AfterIdle as _RunloopSdkAfterIdle,
+ LaunchParameters as _RunloopSdkLaunchParameters,
+)
+from runloop_api_client.types.shared.launch_parameters import (
+ UserParameters as _RunloopSdkUserParameters,
+)
+
+from ....sandbox.entries import Mount
+from ....sandbox.errors import (
+ ExecTimeoutError,
+ ExecTransportError,
+ ExposedPortUnavailableError,
+ InvalidManifestPathError,
+ WorkspaceArchiveReadError,
+ WorkspaceArchiveWriteError,
+ WorkspaceReadNotFoundError,
+ WorkspaceWriteTypeError,
+)
+from ....sandbox.manifest import Manifest
+from ....sandbox.session import SandboxSession, SandboxSessionState
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+from ....sandbox.session.dependencies import Dependencies
+from ....sandbox.session.manager import Instrumentation
+from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
+from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
+from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
+from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
+
+if TYPE_CHECKING:
+ from runloop_api_client.sdk.async_execution_result import (
+ AsyncExecutionResult as RunloopAsyncExecutionResult,
+ )
+ from runloop_api_client.sdk.async_snapshot import AsyncSnapshot as RunloopAsyncSnapshot
+ from runloop_api_client.types.devbox_view import DevboxView as RunloopDevboxView
+
+DEFAULT_RUNLOOP_WORKSPACE_ROOT = "/home/user"
+DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT = "/root"
+_RUNLOOP_DEFAULT_HOME = PurePosixPath("/home/user")
+_RUNLOOP_ROOT_HOME = PurePosixPath("/root")
+_RUNLOOP_SANDBOX_SNAPSHOT_MAGIC = b"RUNLOOP_SANDBOX_SNAPSHOT_V1\n"
+
+logger = logging.getLogger(__name__)
+
+RunloopAfterIdle = _RunloopSdkAfterIdle
+RunloopLaunchParameters = _RunloopSdkLaunchParameters
+RunloopUserParameters = _RunloopSdkUserParameters
+
+
+@dataclass(frozen=True)
+class _RunloopSdkImports:
+ async_sdk: type[Any]
+ api_connection_error: type[BaseException]
+ api_response_validation_error: type[BaseException]
+ api_status_error: type[BaseException]
+ api_timeout_error: type[BaseException]
+ not_found_error: type[BaseException]
+ polling_config: type[Any] | None
+ polling_timeout: type[BaseException] | None
+ runloop_error: type[BaseException]
+
+
+_RUNLOOP_SDK_IMPORTS: _RunloopSdkImports | None = None
+
+
+def _import_runloop_sdk() -> _RunloopSdkImports:
+ global _RUNLOOP_SDK_IMPORTS
+ if _RUNLOOP_SDK_IMPORTS is not None:
+ return _RUNLOOP_SDK_IMPORTS
+
+ try:
+ from runloop_api_client import (
+ APIConnectionError,
+ APIResponseValidationError,
+ APIStatusError,
+ APITimeoutError,
+ NotFoundError,
+ RunloopError,
+ )
+ from runloop_api_client.sdk import AsyncRunloopSDK
+ except ImportError as e:
+ raise ImportError(
+ "RunloopSandboxClient requires the optional `runloop_api_client` dependency.\n"
+ "Install the Runloop extra before using this sandbox backend."
+ ) from e
+
+ polling_config: type[Any] | None = None
+ polling_timeout: type[BaseException] | None = None
+ try:
+ from runloop_api_client.lib.polling import (
+ PollingConfig as RunloopPollingConfig,
+ PollingTimeout as RunloopPollingTimeout,
+ )
+ except ImportError:
+ pass
+ else:
+ polling_config = RunloopPollingConfig
+ polling_timeout = RunloopPollingTimeout
+
+ _RUNLOOP_SDK_IMPORTS = _RunloopSdkImports(
+ async_sdk=AsyncRunloopSDK,
+ api_connection_error=APIConnectionError,
+ api_response_validation_error=APIResponseValidationError,
+ api_status_error=APIStatusError,
+ api_timeout_error=APITimeoutError,
+ not_found_error=NotFoundError,
+ polling_config=polling_config,
+ polling_timeout=polling_timeout,
+ runloop_error=RunloopError,
+ )
+ return _RUNLOOP_SDK_IMPORTS
+
+
+def _encode_runloop_snapshot_ref(*, snapshot_id: str) -> bytes:
+ body = json.dumps({"snapshot_id": snapshot_id}, separators=(",", ":"), sort_keys=True).encode(
+ "utf-8"
+ )
+ return _RUNLOOP_SANDBOX_SNAPSHOT_MAGIC + body
+
+
+def _decode_runloop_snapshot_ref(raw: bytes) -> str | None:
+ if not raw.startswith(_RUNLOOP_SANDBOX_SNAPSHOT_MAGIC):
+ return None
+ body = raw[len(_RUNLOOP_SANDBOX_SNAPSHOT_MAGIC) :]
+ try:
+ obj = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return None
+ snapshot_id = obj.get("snapshot_id") if isinstance(obj, dict) else None
+ return snapshot_id if isinstance(snapshot_id, str) and snapshot_id else None
+
+
+def _runloop_json_safe_body(body: object) -> tuple[str, object] | None:
+ if isinstance(body, str | int | float | bool) or body is None:
+ return ("provider_body", body)
+ if isinstance(body, dict | list):
+ try:
+ json.dumps(body)
+ except TypeError:
+ return ("provider_body_repr", repr(body))
+ return ("provider_body", body)
+ return ("provider_body_repr", repr(body))
+
+
+def _runloop_error_context(
+ exc: BaseException,
+ *,
+ backend_detail: str | None = None,
+) -> dict[str, object]:
+ context: dict[str, object] = {
+ "backend": "runloop",
+ "cause_type": type(exc).__name__,
+ }
+ if backend_detail is not None:
+ context["detail"] = backend_detail
+
+ message = getattr(exc, "message", None)
+ if isinstance(message, str) and message:
+ context["provider_message"] = message
+ else:
+ provider_message = str(exc)
+ if provider_message:
+ context["provider_message"] = provider_message
+
+ status_code = getattr(exc, "status_code", None)
+ response = getattr(exc, "response", None)
+ if not isinstance(status_code, int):
+ response_status = getattr(response, "status_code", None)
+ if isinstance(response_status, int):
+ status_code = response_status
+ if isinstance(status_code, int):
+ context["http_status"] = status_code
+
+ request = getattr(exc, "request", None)
+ request_url = getattr(request, "url", None)
+ if request_url is not None:
+ context["request_url"] = str(request_url)
+ request_method = getattr(request, "method", None)
+ if isinstance(request_method, str) and request_method:
+ context["request_method"] = request_method
+
+ if hasattr(exc, "body"):
+ safe_body = _runloop_json_safe_body(getattr(exc, "body", None))
+ if safe_body is not None:
+ context[safe_body[0]] = safe_body[1]
+
+ return context
+
+
+def _is_runloop_timeout(exc: BaseException) -> bool:
+ polling_timeout = _import_runloop_sdk().polling_timeout
+ if polling_timeout is not None and isinstance(exc, polling_timeout):
+ return True
+ if isinstance(exc, _import_runloop_sdk().api_timeout_error):
+ return True
+ if isinstance(exc, _import_runloop_sdk().api_status_error):
+ status_code = getattr(exc, "status_code", None)
+ response = getattr(exc, "response", None)
+ if not isinstance(status_code, int):
+ response_status = getattr(response, "status_code", None)
+ if isinstance(response_status, int):
+ status_code = response_status
+ return status_code == 408
+ return False
+
+
+def _runloop_status_code(exc: BaseException) -> int | None:
+ status_code = getattr(exc, "status_code", None)
+ response = getattr(exc, "response", None)
+ if not isinstance(status_code, int):
+ response_status = getattr(response, "status_code", None)
+ if isinstance(response_status, int):
+ status_code = response_status
+ return status_code if isinstance(status_code, int) else None
+
+
+def _runloop_error_message(exc: BaseException) -> str | None:
+ body = getattr(exc, "body", None)
+ if isinstance(body, dict):
+ message = body.get("message") or body.get("error")
+ if isinstance(message, str) and message:
+ return message
+
+ message = getattr(exc, "message", None)
+ if isinstance(message, str) and message:
+ return message
+
+ if exc.args:
+ first = exc.args[0]
+ if isinstance(first, str) and first:
+ return first
+
+ return None
+
+
+def _runloop_provider_error_types() -> tuple[type[BaseException], ...]:
+ sdk_imports = _import_runloop_sdk()
+ return (
+ sdk_imports.api_connection_error,
+ sdk_imports.api_response_validation_error,
+ sdk_imports.api_status_error,
+ sdk_imports.runloop_error,
+ )
+
+
+def _is_runloop_not_found(exc: BaseException) -> bool:
+ return isinstance(exc, _import_runloop_sdk().not_found_error)
+
+
+def _is_runloop_conflict(exc: BaseException) -> bool:
+ if not isinstance(exc, _import_runloop_sdk().api_status_error):
+ return False
+
+ status_code = _runloop_status_code(exc)
+ if status_code == 409:
+ return True
+
+ message = _runloop_error_message(exc)
+ if status_code == 400 and isinstance(message, str):
+ return "already exists" in message.lower()
+
+ return False
+
+
+def _runloop_polling_config(*, timeout_s: float | None) -> object | None:
+ if timeout_s is None:
+ return None
+ polling_config = _import_runloop_sdk().polling_config
+ if polling_config is None:
+ return None
+ return cast(object, polling_config(timeout_seconds=max(float(timeout_s), 0.001)))
+
+
+def _is_runloop_provider_error(exc: BaseException) -> bool:
+ return isinstance(
+ exc,
+ _runloop_provider_error_types(),
+ )
+
+
+class RunloopTimeouts(BaseModel):
+ """Timeout configuration for Runloop sandbox operations."""
+
+ model_config = {"frozen": True}
+
+ exec_timeout_unbounded_s: float = Field(default=24 * 60 * 60, ge=1)
+ create_s: float = Field(default=300.0, ge=1)
+ keepalive_s: float = Field(default=10.0, ge=1)
+ cleanup_s: float = Field(default=30.0, ge=1)
+ fast_op_s: float = Field(default=30.0, ge=1)
+ file_upload_s: float = Field(default=1800.0, ge=1)
+ file_download_s: float = Field(default=1800.0, ge=1)
+ snapshot_s: float = Field(default=300.0, ge=1)
+ suspend_s: float = Field(default=120.0, ge=1)
+ resume_s: float = Field(default=300.0, ge=1)
+
+
+class RunloopTunnelConfig(BaseModel):
+ """Runloop public tunnel configuration."""
+
+ model_config = {"frozen": True}
+
+ auth_mode: Literal["open", "authenticated"] | None = None
+ http_keep_alive: bool | None = None
+ wake_on_http: bool | None = None
+
+
+class RunloopGatewaySpec(BaseModel):
+ """Runloop agent gateway binding."""
+
+ model_config = {"frozen": True}
+
+ gateway: str = Field(min_length=1)
+ secret: str = Field(min_length=1)
+
+
+class RunloopMcpSpec(BaseModel):
+ """Runloop MCP gateway binding."""
+
+ model_config = {"frozen": True}
+
+ mcp_config: str = Field(min_length=1)
+ secret: str = Field(min_length=1)
+
+
+def _normalize_runloop_user_parameters(
+ user_parameters: RunloopUserParameters | dict[str, object] | None,
+) -> RunloopUserParameters | None:
+ if isinstance(user_parameters, RunloopUserParameters):
+ return user_parameters
+ if user_parameters is None:
+ return None
+ if isinstance(user_parameters, BaseModel):
+ return RunloopUserParameters.model_validate(user_parameters.model_dump(mode="json"))
+ return RunloopUserParameters.model_validate(user_parameters)
+
+
+def _normalize_runloop_launch_parameters(
+ launch_parameters: RunloopLaunchParameters | dict[str, object] | None,
+) -> RunloopLaunchParameters | None:
+ if isinstance(launch_parameters, RunloopLaunchParameters):
+ return launch_parameters
+ if launch_parameters is None:
+ return None
+ if isinstance(launch_parameters, BaseModel):
+ return RunloopLaunchParameters.model_validate(launch_parameters.model_dump(mode="json"))
+ return RunloopLaunchParameters.model_validate(launch_parameters)
+
+
+def _normalize_runloop_tunnel_config(
+ tunnel: RunloopTunnelConfig | dict[str, object] | None,
+) -> RunloopTunnelConfig | None:
+ if isinstance(tunnel, RunloopTunnelConfig):
+ return tunnel
+ if tunnel is None:
+ return None
+ if isinstance(tunnel, BaseModel):
+ return RunloopTunnelConfig.model_validate(tunnel.model_dump(mode="json"))
+ return RunloopTunnelConfig.model_validate(tunnel)
+
+
+class RunloopSandboxClientOptions(BaseSandboxClientOptions):
+ """Client options for the Runloop sandbox."""
+
+ type: Literal["runloop"] = "runloop"
+ blueprint_id: str | None = None
+ blueprint_name: str | None = None
+ env_vars: dict[str, str] | None = None
+ pause_on_exit: bool = False
+ name: str | None = None
+ timeouts: RunloopTimeouts | dict[str, object] | None = None
+ exposed_ports: tuple[int, ...] = ()
+ user_parameters: RunloopUserParameters | dict[str, object] | None = None
+ launch_parameters: RunloopLaunchParameters | dict[str, object] | None = None
+ tunnel: RunloopTunnelConfig | dict[str, object] | None = None
+ gateways: dict[str, RunloopGatewaySpec] | None = None
+ mcp: dict[str, RunloopMcpSpec] | None = None
+ metadata: dict[str, str] | None = None
+ managed_secrets: dict[str, str] | None = None
+
+ def __init__(
+ self,
+ blueprint_id: str | None = None,
+ blueprint_name: str | None = None,
+ env_vars: dict[str, str] | None = None,
+ pause_on_exit: bool = False,
+ name: str | None = None,
+ timeouts: RunloopTimeouts | dict[str, object] | None = None,
+ exposed_ports: tuple[int, ...] = (),
+ user_parameters: RunloopUserParameters | dict[str, object] | None = None,
+ launch_parameters: RunloopLaunchParameters | dict[str, object] | None = None,
+ tunnel: RunloopTunnelConfig | dict[str, object] | None = None,
+ gateways: dict[str, RunloopGatewaySpec] | None = None,
+ mcp: dict[str, RunloopMcpSpec] | None = None,
+ metadata: dict[str, str] | None = None,
+ managed_secrets: dict[str, str] | None = None,
+ *,
+ type: Literal["runloop"] = "runloop",
+ ) -> None:
+ super().__init__(
+ type=type,
+ blueprint_id=blueprint_id,
+ blueprint_name=blueprint_name,
+ env_vars=env_vars,
+ pause_on_exit=pause_on_exit,
+ name=name,
+ timeouts=timeouts,
+ exposed_ports=exposed_ports,
+ user_parameters=user_parameters,
+ launch_parameters=launch_parameters,
+ tunnel=tunnel,
+ gateways=gateways,
+ mcp=mcp,
+ metadata=metadata,
+ managed_secrets=managed_secrets,
+ )
+
+
+class RunloopSandboxSessionState(SandboxSessionState):
+ """Serializable state for a Runloop-backed session."""
+
+ type: Literal["runloop"] = "runloop"
+ devbox_id: str
+ blueprint_id: str | None = None
+ blueprint_name: str | None = None
+ base_env_vars: dict[str, str] = Field(default_factory=dict)
+ pause_on_exit: bool = False
+ name: str | None = None
+ timeouts: RunloopTimeouts = Field(default_factory=RunloopTimeouts)
+ user_parameters: RunloopUserParameters | None = None
+ launch_parameters: RunloopLaunchParameters | None = None
+ tunnel: RunloopTunnelConfig | None = None
+ gateways: dict[str, RunloopGatewaySpec] = Field(default_factory=dict)
+ mcp: dict[str, RunloopMcpSpec] = Field(default_factory=dict)
+ metadata: dict[str, str] = Field(default_factory=dict)
+ secret_refs: dict[str, str] = Field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class RunloopPlatformBlueprintsClient:
+ _sdk: Any
+
+ async def list(self, **params: object) -> object:
+ return await self._sdk.blueprint.list(**params)
+
+ async def list_public(self, **params: object) -> object:
+ return await self._sdk.api.blueprints.list_public(**params)
+
+ def get(self, blueprint_id: str) -> Any:
+ return self._sdk.blueprint.from_id(blueprint_id)
+
+ async def logs(self, blueprint_id: str, **params: object) -> object:
+ return await self._sdk.api.blueprints.logs(blueprint_id, **params)
+
+ async def create(self, **params: object) -> object:
+ return await self._sdk.blueprint.create(**params)
+
+ async def await_build_complete(self, blueprint_id: str, **params: object) -> object:
+ return await self._sdk.api.blueprints.await_build_complete(blueprint_id, **params)
+
+ async def delete(self, blueprint_id: str, **params: object) -> object:
+ return await self.get(blueprint_id).delete(**params)
+
+
+@dataclass(frozen=True)
+class RunloopPlatformBenchmarksClient:
+ _sdk: Any
+
+ async def list(self, **params: object) -> object:
+ return await self._sdk.benchmark.list(**params)
+
+ async def list_public(self, **params: object) -> object:
+ return await self._sdk.api.benchmarks.list_public(**params)
+
+ def get(self, benchmark_id: str) -> Any:
+ return self._sdk.benchmark.from_id(benchmark_id)
+
+ async def create(self, **params: object) -> object:
+ return await self._sdk.benchmark.create(**params)
+
+ async def update(self, benchmark_id: str, **params: object) -> object:
+ return await self.get(benchmark_id).update(**params)
+
+ async def definitions(self, benchmark_id: str, **params: object) -> object:
+ return await self._sdk.api.benchmarks.definitions(benchmark_id, **params)
+
+ async def start_run(self, benchmark_id: str, **params: object) -> object:
+ return await self.get(benchmark_id).start_run(**params)
+
+ async def update_scenarios(
+ self,
+ benchmark_id: str,
+ *,
+ scenarios_to_add: tuple[str, ...] | Sequence[str] | None = None,
+ scenarios_to_remove: tuple[str, ...] | Sequence[str] | None = None,
+ **params: object,
+ ) -> object:
+ return await self._sdk.api.benchmarks.update_scenarios(
+ benchmark_id,
+ scenarios_to_add=scenarios_to_add,
+ scenarios_to_remove=scenarios_to_remove,
+ **params,
+ )
+
+
+@dataclass(frozen=True)
+class RunloopPlatformSecretsClient:
+ _sdk: Any
+
+ async def create(self, *, name: str, value: str, **params: object) -> object:
+ return await self._sdk.secret.create(name=name, value=value, **params)
+
+ async def list(self, **params: object) -> object:
+ return await self._sdk.secret.list(**params)
+
+ async def get(self, name: str, **params: object) -> object:
+ return await self._sdk.api.secrets.retrieve(name, **params)
+
+ async def update(self, *, name: str, value: str, **params: object) -> object:
+ return await self._sdk.secret.update(name, value=value, **params)
+
+ async def delete(self, name: str, **params: object) -> object:
+ return await self._sdk.secret.delete(name, **params)
+
+
+@dataclass(frozen=True)
+class RunloopPlatformNetworkPoliciesClient:
+ _sdk: Any
+
+ async def create(self, **params: object) -> object:
+ return await self._sdk.network_policy.create(**params)
+
+ async def list(self, **params: object) -> object:
+ return await self._sdk.network_policy.list(**params)
+
+ def get(self, network_policy_id: str) -> Any:
+ return self._sdk.network_policy.from_id(network_policy_id)
+
+ async def update(self, network_policy_id: str, **params: object) -> object:
+ return await self.get(network_policy_id).update(**params)
+
+ async def delete(self, network_policy_id: str, **params: object) -> object:
+ return await self.get(network_policy_id).delete(**params)
+
+
+@dataclass(frozen=True)
+class RunloopPlatformAxonsClient:
+ _sdk: Any
+
+ async def create(self, **params: object) -> object:
+ return await self._sdk.axon.create(**params)
+
+ async def list(self, **params: object) -> object:
+ return await self._sdk.axon.list(**params)
+
+ def get(self, axon_id: str) -> Any:
+ return self._sdk.axon.from_id(axon_id)
+
+ async def publish(self, axon_id: str, **params: object) -> object:
+ return await self.get(axon_id).publish(**params)
+
+ async def query_sql(self, axon_id: str, **params: object) -> object:
+ return await self.get(axon_id).sql.query(**params)
+
+ async def batch_sql(self, axon_id: str, **params: object) -> object:
+ return await self.get(axon_id).sql.batch(**params)
+
+
+@dataclass(frozen=True)
+class RunloopPlatformClient:
+ """Thin facade over the Runloop SDK's non-devbox platform resources."""
+
+ _sdk: Any
+
+ @property
+ def blueprints(self) -> RunloopPlatformBlueprintsClient:
+ return RunloopPlatformBlueprintsClient(self._sdk)
+
+ @property
+ def benchmarks(self) -> RunloopPlatformBenchmarksClient:
+ return RunloopPlatformBenchmarksClient(self._sdk)
+
+ @property
+ def secrets(self) -> RunloopPlatformSecretsClient:
+ return RunloopPlatformSecretsClient(self._sdk)
+
+ @property
+ def network_policies(self) -> RunloopPlatformNetworkPoliciesClient:
+ return RunloopPlatformNetworkPoliciesClient(self._sdk)
+
+ @property
+ def axons(self) -> RunloopPlatformAxonsClient:
+ return RunloopPlatformAxonsClient(self._sdk)
+
+
+class RunloopSandboxSession(BaseSandboxSession):
+ """Runloop-backed sandbox session implementation."""
+
+ state: RunloopSandboxSessionState
+ _sdk: Any
+ _devbox: Any
+ _skip_start: bool
+
+ def __init__(self, *, state: RunloopSandboxSessionState, sdk: Any, devbox: Any) -> None:
+ self.state = state
+ self._sdk = sdk
+ self._devbox = devbox
+ self._skip_start = False
+
+ @classmethod
+ def from_state(
+ cls,
+ state: RunloopSandboxSessionState,
+ *,
+ sdk: Any,
+ devbox: Any,
+ ) -> RunloopSandboxSession:
+ return cls(state=state, sdk=sdk, devbox=devbox)
+
+ @property
+ def devbox_id(self) -> str:
+ return self.state.devbox_id
+
+ @property
+ def runloop_home(self) -> PurePosixPath:
+ return _effective_runloop_home(self.state.user_parameters)
+
+ async def _resolved_envs(self) -> dict[str, str]:
+ manifest_envs = await self.state.manifest.environment.resolve()
+ return {**self.state.base_env_vars, **manifest_envs}
+
+ def _coerce_exec_timeout(self, timeout_s: float | None) -> float:
+ if timeout_s is None:
+ return float(self.state.timeouts.exec_timeout_unbounded_s)
+ if timeout_s <= 0:
+ return 0.001
+ return float(timeout_s)
+
+ async def start(self) -> None:
+ """Resume a reconnected Runloop devbox without replaying full setup when possible.
+
+ `resume()` marks `_skip_start` when it successfully reconnects to a suspended devbox.
+ In that path, Runloop reuses the live machine and only reapplies snapshot or ephemeral
+ manifest state if the cached workspace fingerprint no longer matches.
+ """
+ if self._skip_start:
+ if await self.state.snapshot.restorable(dependencies=self.dependencies):
+ is_running = await self.running()
+ fingerprints_match = await self._can_skip_snapshot_restore_on_resume(
+ is_running=is_running
+ )
+ if fingerprints_match:
+ await self._reapply_ephemeral_manifest_on_resume()
+ else:
+ await self._restore_snapshot_into_workspace_on_resume()
+ if self.should_provision_manifest_accounts_on_resume():
+ await self.provision_manifest_accounts()
+ await self._reapply_ephemeral_manifest_on_resume()
+ else:
+ await self._reapply_ephemeral_manifest_on_resume()
+ return
+ await super().start()
+
+ async def shutdown(self) -> None:
+ """Suspend or delete the underlying Runloop devbox as the final session cleanup step.
+
+ `pause_on_exit=True` maps to Runloop suspension so the same devbox can be resumed later.
+ Otherwise the session shuts the devbox down and treats it as disposable.
+ """
+ try:
+ if self.state.pause_on_exit:
+ await self._devbox.suspend(timeout=self.state.timeouts.suspend_s)
+ await self._devbox.await_suspended()
+ else:
+ await self._devbox.shutdown(timeout=self.state.timeouts.cleanup_s)
+ except Exception:
+ pass
+
+ def supports_pty(self) -> bool:
+ return False
+
+ def _path_relative_to_home(self, path: Path | str) -> str:
+ normalized = PurePosixPath(str(self.normalize_path(path)))
+ try:
+ relative = normalized.relative_to(self.runloop_home)
+ except ValueError as e:
+ raise InvalidManifestPathError(
+ rel=Path(str(normalized)),
+ reason="absolute",
+ cause=e,
+ ) from e
+ rel_str = relative.as_posix()
+ return rel_str if rel_str else "."
+
+ async def _wrap_command_in_workspace_context(self, command: str) -> str:
+ root_q = shlex.quote(self.state.manifest.root)
+ envs = await self._resolved_envs()
+ if not envs:
+ return f"cd {root_q} && {command}"
+
+ env_assignments = " ".join(
+ shlex.quote(f"{key}={value}") for key, value in sorted(envs.items())
+ )
+ return f"cd {root_q} && env -- {env_assignments} {command}"
+
+ async def _exec_internal(
+ self,
+ *command: str | Path,
+ timeout: float | None = None,
+ ) -> ExecResult:
+ cmd_str = await self._wrap_command_in_workspace_context(shlex.join(str(c) for c in command))
+ return await self._run_exec_command(
+ cmd_str,
+ command=command,
+ timeout=timeout,
+ )
+
+ async def _run_exec_command(
+ self,
+ cmd_str: str,
+ *,
+ command: tuple[str | Path, ...],
+ timeout: float | None,
+ ) -> ExecResult:
+ caller_timeout = self._coerce_exec_timeout(timeout)
+ request_timeout = min(caller_timeout, self.state.timeouts.fast_op_s)
+ polling_config = _runloop_polling_config(timeout_s=caller_timeout)
+
+ try:
+ result: RunloopAsyncExecutionResult = await asyncio.wait_for(
+ self._devbox.cmd.exec(
+ cmd_str,
+ timeout=request_timeout,
+ polling_config=polling_config,
+ ),
+ timeout=caller_timeout,
+ )
+ stdout = (await result.stdout()).encode("utf-8", errors="replace")
+ stderr = (await result.stderr()).encode("utf-8", errors="replace")
+ exit_code = int(result.exit_code or 0)
+ return ExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code)
+ except asyncio.TimeoutError as e:
+ raise ExecTimeoutError(
+ command=command,
+ timeout_s=timeout,
+ context=_runloop_error_context(e, backend_detail="exec_timeout"),
+ cause=e,
+ ) from e
+ except Exception as e:
+ if _is_runloop_timeout(e):
+ raise ExecTimeoutError(
+ command=command,
+ timeout_s=timeout,
+ context=_runloop_error_context(e, backend_detail="exec_timeout"),
+ cause=e,
+ ) from e
+ if _is_runloop_provider_error(e):
+ raise ExecTransportError(
+ command=command,
+ context=_runloop_error_context(e, backend_detail="exec_failed"),
+ cause=e,
+ ) from e
+ raise ExecTransportError(command=command, cause=e) from e
+
+ async def _ensure_tunnel_url(self, port: int) -> str:
+ try:
+ url = await self._devbox.get_tunnel_url(port, timeout=self.state.timeouts.fast_op_s)
+ except Exception as e:
+ if _is_runloop_provider_error(e):
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context=_runloop_error_context(e, backend_detail="get_tunnel_url_failed"),
+ cause=e,
+ ) from e
+ raise
+ if isinstance(url, str) and url:
+ return url
+
+ try:
+ await self._devbox.net.enable_tunnel(
+ auth_mode="open",
+ http_keep_alive=True,
+ wake_on_http=False,
+ timeout=self.state.timeouts.fast_op_s,
+ )
+ except Exception as e:
+ if _is_runloop_provider_error(e):
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context=_runloop_error_context(e, backend_detail="enable_tunnel_failed"),
+ cause=e,
+ ) from e
+ raise
+ try:
+ url = await self._devbox.get_tunnel_url(port, timeout=self.state.timeouts.fast_op_s)
+ except Exception as e:
+ if _is_runloop_provider_error(e):
+ context = _runloop_error_context(e, backend_detail="get_tunnel_url_failed")
+ context["phase"] = "post_enable"
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context=context,
+ cause=e,
+ ) from e
+ raise
+ if not isinstance(url, str) or not url:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "runloop", "detail": "missing_tunnel_url"},
+ )
+ return url
+
+ async def resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
+ """Resolve an exposed Runloop port through the provider-managed tunnel endpoint.
+
+ Runloop may not have a tunnel enabled for a devbox yet, so exposed-port resolution can
+ trigger tunnel creation before returning the public host, port, and TLS settings.
+ """
+
+ return await super().resolve_exposed_port(port)
+
+ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
+ try:
+ url = await self._ensure_tunnel_url(port)
+ split = urlsplit(url)
+ host = split.hostname
+ if host is None:
+ raise ValueError("missing hostname")
+ port_value = split.port or (443 if split.scheme == "https" else 80)
+ return ExposedPortEndpoint(host=host, port=port_value, tls=split.scheme == "https")
+ except ExposedPortUnavailableError:
+ raise
+ except Exception as e:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "runloop", "detail": "invalid_tunnel_url"},
+ cause=e,
+ ) from e
+
+ async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
+ """Read a file via Runloop's binary file API using home-relative addressing.
+
+ Callers use manifest-root paths, and the backend converts them into the relative file paths
+ that Runloop expects when downloading workspace contents from the devbox.
+ """
+ path = Path(path)
+ if user is not None:
+ await self._check_read_with_exec(path, user=user)
+
+ rel_path = self._path_relative_to_home(path)
+ try:
+ payload = await self._devbox.file.download(
+ path=rel_path,
+ timeout=self.state.timeouts.file_download_s,
+ )
+ return io.BytesIO(bytes(payload))
+ except Exception as e:
+ if _is_runloop_not_found(e):
+ raise WorkspaceReadNotFoundError(
+ path=path,
+ context=_runloop_error_context(e, backend_detail="file_download_failed"),
+ cause=e,
+ ) from e
+ if _is_runloop_provider_error(e):
+ raise WorkspaceArchiveReadError(
+ path=path,
+ context=_runloop_error_context(e, backend_detail="file_download_failed"),
+ cause=e,
+ ) from e
+ raise WorkspaceArchiveReadError(path=path, cause=e) from e
+
+ async def write(
+ self,
+ path: Path | str,
+ data: io.IOBase,
+ *,
+ user: str | User | None = None,
+ ) -> None:
+ """Write a file through Runloop's upload API using manifest-root workspace paths.
+
+ The session ensures parent directories exist inside the devbox, then translates the target
+ into the active home-relative path that Runloop's file upload endpoint accepts.
+ """
+ path = Path(path)
+ if user is not None:
+ await self._check_write_with_exec(path, user=user)
+
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=path, actual_type=type(payload).__name__)
+
+ workspace_path = self.normalize_path(path)
+ rel_path = self._path_relative_to_home(workspace_path)
+ await self.mkdir(workspace_path.parent, parents=True)
+ try:
+ await self._devbox.file.upload(
+ path=rel_path,
+ file=bytes(payload),
+ timeout=self.state.timeouts.file_upload_s,
+ )
+ except Exception as e:
+ if _is_runloop_provider_error(e):
+ raise WorkspaceArchiveWriteError(
+ path=workspace_path,
+ context=_runloop_error_context(e, backend_detail="file_upload_failed"),
+ cause=e,
+ ) from e
+ raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
+
+ async def running(self) -> bool:
+ """Report whether the current Runloop devbox is still in the `running` backend state.
+
+ Resume logic relies on this backend status check before deciding whether a suspended devbox
+ can be reused directly or whether snapshot restore must rebuild the workspace elsewhere.
+ """
+ try:
+ info: RunloopDevboxView = await self._devbox.get_info(
+ timeout=self.state.timeouts.keepalive_s
+ )
+ return cast(str, info.status) == "running"
+ except Exception:
+ return False
+
+ async def mkdir(
+ self,
+ path: Path | str,
+ *,
+ parents: bool = False,
+ user: str | User | None = None,
+ ) -> None:
+ """Create directories via raw exec so workspace-root creation does not depend on `cd`."""
+
+ if user is not None:
+ path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
+ else:
+ path = self.normalize_path(path)
+ cmd = ["mkdir"]
+ if parents:
+ cmd.append("-p")
+ cmd.extend(["--", str(path)])
+ result = await self._run_exec_command(
+ shlex.join(cmd),
+ command=tuple(cmd),
+ timeout=self.state.timeouts.fast_op_s,
+ )
+ if not result.ok():
+ raise WorkspaceArchiveWriteError(
+ path=path,
+ context={
+ "reason": "mkdir_failed",
+ "exit_code": result.exit_code,
+ "stderr": result.stderr.decode("utf-8", "replace"),
+ },
+ )
+
+ async def _backup_plain_skip_paths(self, plain_skip: set[Path]) -> bytes | None:
+ if not plain_skip:
+ return None
+
+ root = self.state.manifest.root
+ root_q = shlex.quote(root)
+ checks = "\n".join(
+ (
+ f"if [ -e {shlex.quote(rel.as_posix())} ]; then "
+ f'set -- "$@" {shlex.quote(rel.as_posix())}; fi'
+ )
+ for rel in sorted(plain_skip, key=lambda p: p.as_posix())
+ )
+ command = (
+ f"cd {root_q}\n"
+ "set --\n"
+ f"{checks}\n"
+ 'if [ "$#" -eq 0 ]; then exit 0; fi\n'
+ 'tar -cf - "$@" | base64 -w0\n'
+ )
+ result = await self.exec(command, shell=True, timeout=self.state.timeouts.snapshot_s)
+ if not result.ok():
+ raise WorkspaceArchiveReadError(
+ path=Path(root),
+ context={
+ "reason": "ephemeral_backup_failed",
+ "exit_code": result.exit_code,
+ "stderr": result.stderr.decode("utf-8", "replace"),
+ },
+ )
+ encoded = result.stdout.decode("utf-8", "replace").strip()
+ if not encoded:
+ return None
+ try:
+ return io.BytesIO(base64.b64decode(encoded.encode("utf-8"), validate=True)).read()
+ except Exception as e:
+ raise WorkspaceArchiveReadError(
+ path=Path(root),
+ context={"reason": "ephemeral_backup_invalid_base64"},
+ cause=e,
+ ) from e
+
+ async def _remove_plain_skip_paths(self, plain_skip: set[Path]) -> None:
+ if not plain_skip:
+ return
+ root = Path(self.state.manifest.root)
+ command = ["rm", "-rf", "--"] + [str(root / rel) for rel in sorted(plain_skip)]
+ result = await self.exec(*command, shell=False, timeout=self.state.timeouts.cleanup_s)
+ if not result.ok():
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "ephemeral_remove_failed",
+ "exit_code": result.exit_code,
+ "stderr": result.stderr.decode("utf-8", "replace"),
+ },
+ )
+
+ async def _restore_plain_skip_paths(self, backup: bytes | None) -> None:
+ if not backup:
+ return
+ root = Path(self.state.manifest.root)
+ temp_path = (
+ Path(self.state.manifest.root)
+ / f".sandbox-runloop-restore-{self.state.session_id.hex}.tar"
+ )
+ await self.write(temp_path, io.BytesIO(backup))
+ try:
+ result = await self.exec(
+ "mkdir",
+ "-p",
+ str(root),
+ shell=False,
+ timeout=self.state.timeouts.cleanup_s,
+ )
+ if not result.ok():
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "ephemeral_restore_mkdir_failed",
+ "exit_code": result.exit_code,
+ },
+ )
+ result = await self.exec(
+ "tar",
+ "-xf",
+ str(temp_path),
+ "-C",
+ str(root),
+ shell=False,
+ timeout=self.state.timeouts.snapshot_s,
+ )
+ if not result.ok():
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "ephemeral_restore_failed",
+ "exit_code": result.exit_code,
+ "stderr": result.stderr.decode("utf-8", "replace"),
+ },
+ )
+ finally:
+ try:
+ await self.exec("rm", "-f", "--", str(temp_path), shell=False)
+ except Exception:
+ pass
+
+ async def persist_workspace(self) -> io.IOBase:
+ """Persist the workspace with a native Runloop disk snapshot.
+
+ Before snapshotting, the session temporarily removes ephemeral skip paths and tears down
+ ephemeral mounts so the saved disk image contains only durable workspace state, then it
+ restores those local-only artifacts afterward.
+ """
+ root = Path(self.state.manifest.root)
+ skip = self._persist_workspace_skip_relpaths()
+ mount_targets = self.state.manifest.ephemeral_mount_targets()
+ mount_skip_rel_paths: set[Path] = set()
+ for _mount_entry, mount_path in mount_targets:
+ try:
+ mount_skip_rel_paths.add(mount_path.relative_to(root))
+ except ValueError:
+ continue
+ plain_skip = skip - mount_skip_rel_paths
+
+ backup: bytes | None = None
+ unmounted_mounts: list[tuple[Mount, Path]] = []
+ snapshot_error: WorkspaceArchiveReadError | None = None
+ snapshot_id: str | None = None
+
+ try:
+ backup = await self._backup_plain_skip_paths(plain_skip)
+ await self._remove_plain_skip_paths(plain_skip)
+
+ for mount_entry, mount_path in mount_targets:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry,
+ self,
+ mount_path,
+ )
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ snapshot: RunloopAsyncSnapshot = await self._devbox.snapshot_disk(
+ name=f"sandbox-{self.state.session_id.hex[:12]}",
+ metadata={"openai_agents_session_id": self.state.session_id.hex},
+ timeout=self.state.timeouts.snapshot_s,
+ )
+ snapshot_id = snapshot.id
+ if not snapshot_id:
+ raise WorkspaceArchiveReadError(
+ path=root,
+ context={
+ "reason": "snapshot_unexpected_return",
+ "type": type(snapshot).__name__,
+ },
+ )
+ except WorkspaceArchiveReadError as e:
+ snapshot_error = e
+ except Exception as e:
+ snapshot_error = WorkspaceArchiveReadError(
+ path=root,
+ context={"reason": "snapshot_failed"},
+ cause=e,
+ )
+ finally:
+ remount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as e:
+ current_error = WorkspaceArchiveReadError(path=root, cause=e)
+ if remount_error is None:
+ remount_error = current_error
+ else:
+ additional = remount_error.context.setdefault(
+ "additional_remount_errors", []
+ )
+ assert isinstance(additional, list)
+ additional.append(
+ {
+ "message": current_error.message,
+ "cause_type": type(e).__name__,
+ "cause": str(e),
+ }
+ )
+ try:
+ await self._restore_plain_skip_paths(backup)
+ except Exception as e:
+ restore_error = WorkspaceArchiveReadError(path=root, cause=e)
+ if remount_error is None:
+ remount_error = restore_error
+ else:
+ additional = remount_error.context.setdefault("additional_restore_errors", [])
+ assert isinstance(additional, list)
+ additional.append(
+ {
+ "message": restore_error.message,
+ "cause_type": type(e).__name__,
+ "cause": str(e),
+ }
+ )
+
+ if remount_error is not None:
+ if snapshot_error is not None:
+ remount_error.context["snapshot_error_before_restore_corruption"] = {
+ "message": snapshot_error.message
+ }
+ raise remount_error
+
+ if snapshot_error is not None:
+ raise snapshot_error
+
+ assert snapshot_id is not None
+ return io.BytesIO(_encode_runloop_snapshot_ref(snapshot_id=snapshot_id))
+
+ async def hydrate_workspace(self, data: io.IOBase) -> None:
+ """Replace the current devbox from a Runloop snapshot reference or tar archive.
+
+ Runloop restore creates a new devbox from the saved disk snapshot and treats that snapshot
+ filesystem as authoritative, including any tools or files that originally came from the
+ source blueprint, so restore does not reselect a blueprint. Non-native payloads fall back
+ to tar hydration so cross-provider snapshots and file snapshots keep working.
+ """
+ root = Path(self.state.manifest.root)
+ raw = data.read()
+ if isinstance(raw, str):
+ raw = raw.encode("utf-8")
+ if not isinstance(raw, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=root, actual_type=type(raw).__name__)
+
+ snapshot_id = _decode_runloop_snapshot_ref(bytes(raw))
+ if snapshot_id is None:
+ await self._hydrate_workspace_via_tar(bytes(raw))
+ return
+
+ try:
+ try:
+ await self._devbox.shutdown(timeout=self.state.timeouts.cleanup_s)
+ except Exception:
+ pass
+ envs = await self._resolved_envs()
+ create_kwargs = _runloop_create_kwargs(
+ blueprint_id=None,
+ blueprint_name=None,
+ env_vars=envs,
+ name=self.state.name,
+ user_parameters=self.state.user_parameters,
+ launch_parameters=self.state.launch_parameters,
+ tunnel=self.state.tunnel,
+ gateways=self.state.gateways,
+ mcp=self.state.mcp,
+ metadata=self.state.metadata,
+ secrets=self.state.secret_refs,
+ )
+ devbox = await self._sdk.devbox.create_from_snapshot(
+ snapshot_id,
+ timeout=self.state.timeouts.resume_s,
+ **create_kwargs,
+ )
+ self._devbox = devbox
+ self.state.devbox_id = devbox.id
+ except Exception as e:
+ context: dict[str, object] = {
+ "reason": "snapshot_restore_failed",
+ "snapshot_id": snapshot_id,
+ }
+ if _is_runloop_provider_error(e):
+ context.update(_runloop_error_context(e, backend_detail="snapshot_restore_failed"))
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context=context,
+ cause=e,
+ ) from e
+
+ async def _restore_snapshot_into_workspace_on_resume(self) -> None:
+ """Restore snapshots on resume, preserving Runloop's native disk-snapshot fast path."""
+
+ root = Path(self.state.manifest.root)
+ workspace_archive = await self.state.snapshot.restore(dependencies=self.dependencies)
+ try:
+ raw = workspace_archive.read()
+ if isinstance(raw, str):
+ raw = raw.encode("utf-8")
+ if not isinstance(raw, bytes | bytearray):
+ raise WorkspaceWriteTypeError(path=root, actual_type=type(raw).__name__)
+
+ payload = bytes(raw)
+ if _decode_runloop_snapshot_ref(payload) is None:
+ # Most providers restore tar snapshots by clearing the workspace first, then
+ # extracting into an empty root. Runloop differs only for its native snapshot
+ # refs, which already replace the entire devbox disk and therefore should not
+ # pre-clear the workspace root on resume.
+ await self._clear_workspace_root_on_resume()
+ await self.hydrate_workspace(io.BytesIO(payload))
+ finally:
+ try:
+ workspace_archive.close()
+ except Exception:
+ pass
+
+ async def _hydrate_workspace_via_tar(self, payload: bytes) -> None:
+ root = Path(self.state.manifest.root)
+ archive_path = root / f".sandbox-runloop-hydrate-{self.state.session_id.hex}.tar"
+
+ try:
+ validate_tar_bytes(payload)
+ except UnsafeTarMemberError as e:
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "unsafe_or_invalid_tar",
+ "member": e.member,
+ "detail": str(e),
+ },
+ cause=e,
+ ) from e
+
+ try:
+ await self.mkdir(root, parents=True)
+ await self.write(archive_path, io.BytesIO(payload))
+ result = await self.exec(
+ "tar",
+ "-C",
+ str(root),
+ "-xf",
+ str(archive_path),
+ shell=False,
+ timeout=self.state.timeouts.snapshot_s,
+ )
+ if not result.ok():
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ context={
+ "reason": "tar_extract_failed",
+ "exit_code": result.exit_code,
+ "stderr": result.stderr.decode("utf-8", errors="replace"),
+ },
+ )
+ except WorkspaceArchiveWriteError:
+ raise
+ except Exception as e:
+ raise WorkspaceArchiveWriteError(path=root, cause=e) from e
+ finally:
+ try:
+ await self.exec(
+ "rm",
+ "-f",
+ "--",
+ str(archive_path),
+ shell=False,
+ timeout=self.state.timeouts.cleanup_s,
+ )
+ except Exception:
+ pass
+
+
+def _runloop_create_kwargs(
+ *,
+ blueprint_id: str | None,
+ blueprint_name: str | None,
+ env_vars: dict[str, str] | None,
+ name: str | None,
+ user_parameters: RunloopUserParameters | None,
+ launch_parameters: RunloopLaunchParameters | None,
+ tunnel: RunloopTunnelConfig | None,
+ gateways: dict[str, RunloopGatewaySpec],
+ mcp: dict[str, RunloopMcpSpec],
+ metadata: dict[str, str],
+ secrets: dict[str, str],
+) -> dict[str, object]:
+ kwargs: dict[str, object] = {}
+ if blueprint_id is not None:
+ kwargs["blueprint_id"] = blueprint_id
+ if blueprint_name is not None:
+ kwargs["blueprint_name"] = blueprint_name
+ if env_vars:
+ kwargs["environment_variables"] = env_vars
+ if name:
+ kwargs["name"] = name
+ launch_parameters_payload = _runloop_launch_parameters_payload(
+ launch_parameters=launch_parameters,
+ user_parameters=user_parameters,
+ )
+ if launch_parameters_payload is not None:
+ kwargs["launch_parameters"] = launch_parameters_payload
+ if tunnel is not None:
+ kwargs["tunnel"] = tunnel.model_dump(mode="json", exclude_none=True)
+ if gateways:
+ kwargs["gateways"] = {
+ key: value.model_dump(mode="json", exclude_none=True) for key, value in gateways.items()
+ }
+ if mcp:
+ kwargs["mcp"] = {
+ key: value.model_dump(mode="json", exclude_none=True) for key, value in mcp.items()
+ }
+ if metadata:
+ kwargs["metadata"] = metadata
+ if secrets:
+ kwargs["secrets"] = secrets
+ return kwargs
+
+
+def _runloop_launch_parameters_payload(
+ *,
+ launch_parameters: RunloopLaunchParameters | None,
+ user_parameters: RunloopUserParameters | None,
+) -> dict[str, object] | None:
+ payload = (
+ launch_parameters.to_dict(mode="json", exclude_none=True, exclude_defaults=True)
+ if launch_parameters is not None
+ else {}
+ )
+ if user_parameters is not None:
+ payload["user_parameters"] = user_parameters.to_dict(mode="json", exclude_none=True)
+ return payload or None
+
+
+async def _upsert_runloop_managed_secrets(
+ sdk: Any,
+ *,
+ managed_secrets: dict[str, str] | None,
+ timeout_s: float,
+) -> dict[str, str]:
+ if not managed_secrets:
+ return {}
+
+ secret_refs: dict[str, str] = {}
+ for env_var, secret_value in sorted(managed_secrets.items()):
+ try:
+ await sdk.secret.create(name=env_var, value=secret_value, timeout=timeout_s)
+ except Exception as e:
+ if _is_runloop_conflict(e):
+ await sdk.secret.update(env_var, value=secret_value, timeout=timeout_s)
+ else:
+ raise
+ secret_refs[env_var] = env_var
+ return secret_refs
+
+
+def _effective_runloop_home(user_parameters: RunloopUserParameters | None) -> PurePosixPath:
+ if user_parameters is None:
+ return _RUNLOOP_DEFAULT_HOME
+ if user_parameters.username == "root" and user_parameters.uid == 0:
+ return _RUNLOOP_ROOT_HOME
+ return PurePosixPath("/home") / user_parameters.username
+
+
+def _default_runloop_manifest_root(user_parameters: RunloopUserParameters | None) -> str:
+ return str(_effective_runloop_home(user_parameters))
+
+
+def _validate_runloop_manifest_root(
+ manifest: Manifest, *, user_parameters: RunloopUserParameters | None
+) -> None:
+ root = PurePosixPath(os.path.normpath(manifest.root))
+ runloop_home = _effective_runloop_home(user_parameters)
+ try:
+ root.relative_to(runloop_home)
+ except ValueError as e:
+ raise ValueError(
+ "RunloopSandboxClient requires manifest.root to be the effective Runloop home "
+ f"({runloop_home}) or a subdirectory of it."
+ ) from e
+
+
+class RunloopSandboxClient(BaseSandboxClient[RunloopSandboxClientOptions | None]):
+ """Runloop sandbox client managing devbox lifecycle via AsyncRunloopSDK."""
+
+ backend_id = "runloop"
+ supports_default_options = True
+ _instrumentation: Instrumentation
+ _platform: RunloopPlatformClient
+
+ def __init__(
+ self,
+ *,
+ bearer_token: str | None = None,
+ base_url: str | None = None,
+ instrumentation: Instrumentation | None = None,
+ dependencies: Dependencies | None = None,
+ ) -> None:
+ self._sdk = _import_runloop_sdk().async_sdk(bearer_token=bearer_token, base_url=base_url)
+ self._platform = RunloopPlatformClient(self._sdk)
+ self._instrumentation = instrumentation or Instrumentation()
+ self._dependencies = dependencies
+
+ @property
+ def platform(self) -> RunloopPlatformClient:
+ return self._platform
+
+ async def create(
+ self,
+ *,
+ snapshot: SnapshotSpec | SnapshotBase | None = None,
+ manifest: Manifest | None = None,
+ options: RunloopSandboxClientOptions | None,
+ ) -> SandboxSession:
+ """Create a Runloop devbox and bind it to a manifest rooted under the active home.
+
+ Runloop defaults to the `user` account at `/home/user`, but explicit user parameters can
+ switch the active home, including root launch at `/root`. Client creation validates the
+ manifest root against that effective home, merges environment variables, and applies any
+ configured blueprint selection or user profile when provisioning the devbox. The returned
+ session follows the shared sandbox lifecycle and must be started before direct operations.
+ """
+ resolved_options = options or RunloopSandboxClientOptions()
+ if (
+ resolved_options.blueprint_id is not None
+ and resolved_options.blueprint_name is not None
+ ):
+ raise ValueError(
+ "RunloopSandboxClientOptions cannot set both blueprint_id and blueprint_name"
+ )
+
+ user_parameters = _normalize_runloop_user_parameters(resolved_options.user_parameters)
+ manifest = manifest or Manifest(root=_default_runloop_manifest_root(user_parameters))
+ _validate_runloop_manifest_root(manifest, user_parameters=user_parameters)
+
+ timeouts_in = resolved_options.timeouts
+ if isinstance(timeouts_in, RunloopTimeouts):
+ timeouts = timeouts_in
+ elif timeouts_in is None:
+ timeouts = RunloopTimeouts()
+ else:
+ timeouts = RunloopTimeouts.model_validate(timeouts_in)
+
+ secret_refs = await _upsert_runloop_managed_secrets(
+ self._sdk,
+ managed_secrets=resolved_options.managed_secrets,
+ timeout_s=timeouts.fast_op_s,
+ )
+ launch_parameters = _normalize_runloop_launch_parameters(resolved_options.launch_parameters)
+ tunnel = _normalize_runloop_tunnel_config(resolved_options.tunnel)
+ base_envs = dict(resolved_options.env_vars or {})
+ manifest_envs = await manifest.environment.resolve()
+ envs = {**base_envs, **manifest_envs} or None
+
+ create_kwargs = _runloop_create_kwargs(
+ blueprint_id=resolved_options.blueprint_id,
+ blueprint_name=resolved_options.blueprint_name,
+ env_vars=envs,
+ name=resolved_options.name,
+ user_parameters=user_parameters,
+ launch_parameters=launch_parameters,
+ tunnel=tunnel,
+ gateways=dict(resolved_options.gateways or {}),
+ mcp=dict(resolved_options.mcp or {}),
+ metadata=dict(resolved_options.metadata or {}),
+ secrets=secret_refs,
+ )
+ devbox = await self._sdk.devbox.create(timeout=timeouts.create_s, **create_kwargs)
+
+ session_id = uuid.uuid4()
+ snapshot_instance = resolve_snapshot(snapshot, str(session_id))
+ state = RunloopSandboxSessionState(
+ session_id=session_id,
+ manifest=manifest,
+ snapshot=snapshot_instance,
+ devbox_id=devbox.id,
+ blueprint_id=resolved_options.blueprint_id,
+ blueprint_name=resolved_options.blueprint_name,
+ base_env_vars=base_envs,
+ pause_on_exit=resolved_options.pause_on_exit,
+ name=resolved_options.name,
+ timeouts=timeouts,
+ exposed_ports=resolved_options.exposed_ports,
+ user_parameters=user_parameters,
+ launch_parameters=launch_parameters,
+ tunnel=tunnel,
+ gateways=dict(resolved_options.gateways or {}),
+ mcp=dict(resolved_options.mcp or {}),
+ metadata=dict(resolved_options.metadata or {}),
+ secret_refs=secret_refs,
+ )
+ inner = RunloopSandboxSession.from_state(state, sdk=self._sdk, devbox=devbox)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ async def close(self) -> None:
+ """Close the shared AsyncRunloopSDK client used for devbox operations."""
+ await self._sdk.aclose()
+
+ async def __aenter__(self) -> RunloopSandboxClient:
+ return self
+
+ async def __aexit__(self, *_: object) -> None:
+ await self.close()
+
+ async def delete(self, session: SandboxSession) -> SandboxSession:
+ """Best-effort release the Runloop devbox when callers delete the session."""
+ inner = session._inner
+ if not isinstance(inner, RunloopSandboxSession):
+ raise TypeError("RunloopSandboxClient.delete expects a RunloopSandboxSession")
+ try:
+ await inner.shutdown()
+ except Exception:
+ pass
+ return session
+
+ async def resume(
+ self,
+ state: SandboxSessionState,
+ ) -> SandboxSession:
+ """Resume a persisted Runloop session by reconnecting or reprovisioning a devbox.
+
+ The client first tries to reconnect to the stored devbox id, including after an unclean
+ process/client shutdown where the devbox is still running and `shutdown()` was never
+ called. If reconnect fails, it creates a fresh devbox with the stored blueprint and
+ environment settings.
+ """
+ if not isinstance(state, RunloopSandboxSessionState):
+ raise TypeError("RunloopSandboxClient.resume expects a RunloopSandboxSessionState")
+
+ devbox = None
+ reconnected = False
+ try:
+ devbox = self._sdk.devbox.from_id(state.devbox_id)
+ info: RunloopDevboxView = await devbox.get_info(timeout=state.timeouts.keepalive_s)
+ status = info.status
+ resume_polling_config = _runloop_polling_config(timeout_s=state.timeouts.resume_s)
+ if status == "suspended":
+ await devbox.resume(timeout=state.timeouts.resume_s)
+ await devbox.await_running(polling_config=resume_polling_config)
+ elif status == "resuming":
+ await devbox.await_running(polling_config=resume_polling_config)
+ elif status != "running":
+ raise RuntimeError(f"unexpected_status:{status}")
+ reconnected = True
+ except Exception:
+ devbox = None
+
+ if devbox is None:
+ manifest_envs = await state.manifest.environment.resolve()
+ envs = {**state.base_env_vars, **manifest_envs} or None
+ create_kwargs = _runloop_create_kwargs(
+ blueprint_id=state.blueprint_id,
+ blueprint_name=state.blueprint_name,
+ env_vars=envs,
+ name=state.name,
+ user_parameters=state.user_parameters,
+ launch_parameters=state.launch_parameters,
+ tunnel=state.tunnel,
+ gateways=state.gateways,
+ mcp=state.mcp,
+ metadata=state.metadata,
+ secrets=state.secret_refs,
+ )
+ devbox = await self._sdk.devbox.create(timeout=state.timeouts.create_s, **create_kwargs)
+ state.devbox_id = devbox.id
+
+ inner = RunloopSandboxSession.from_state(state, sdk=self._sdk, devbox=devbox)
+ inner._skip_start = state.pause_on_exit and reconnected
+ inner._set_start_state_preserved(reconnected, system=reconnected)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
+ return RunloopSandboxSessionState.model_validate(payload)
diff --git a/src/agents/extensions/sandbox/vercel/__init__.py b/src/agents/extensions/sandbox/vercel/__init__.py
new file mode 100644
index 00000000..fd525ae6
--- /dev/null
+++ b/src/agents/extensions/sandbox/vercel/__init__.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from .sandbox import (
+ VercelSandboxClient,
+ VercelSandboxClientOptions,
+ VercelSandboxSession,
+ VercelSandboxSessionState,
+)
+
+__all__ = [
+ "VercelSandboxClient",
+ "VercelSandboxClientOptions",
+ "VercelSandboxSession",
+ "VercelSandboxSessionState",
+]
diff --git a/src/agents/extensions/sandbox/vercel/sandbox.py b/src/agents/extensions/sandbox/vercel/sandbox.py
new file mode 100644
index 00000000..6bc14876
--- /dev/null
+++ b/src/agents/extensions/sandbox/vercel/sandbox.py
@@ -0,0 +1,908 @@
+"""
+Vercel sandbox (https://vercel.com) implementation.
+
+This module provides a Vercel-backed sandbox client/session implementation backed by
+`vercel.sandbox.AsyncSandbox`.
+
+The `vercel` dependency is optional, so package-level exports should guard imports of this
+module. Within this module, Vercel SDK imports are normal so users with the extra installed get
+full type navigation.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import io
+import json
+import os
+import tarfile
+import uuid
+from collections.abc import Awaitable, Callable
+from pathlib import Path, PurePosixPath
+from typing import Any, Literal, cast
+from urllib.parse import urlsplit
+
+import httpx
+from pydantic import TypeAdapter, field_serializer, field_validator
+from vercel.sandbox import (
+ AsyncSandbox,
+ NetworkPolicy,
+ Resources,
+ SandboxStatus,
+ SnapshotSource,
+)
+
+from ....sandbox.errors import (
+ ConfigurationError,
+ ErrorCode,
+ ExecNonZeroError,
+ ExecTimeoutError,
+ ExecTransportError,
+ ExposedPortUnavailableError,
+ InvalidManifestPathError,
+ WorkspaceArchiveReadError,
+ WorkspaceArchiveWriteError,
+ WorkspaceReadNotFoundError,
+ WorkspaceStartError,
+ WorkspaceWriteTypeError,
+)
+from ....sandbox.manifest import Manifest
+from ....sandbox.session import SandboxSession, SandboxSessionState
+from ....sandbox.session.base_sandbox_session import BaseSandboxSession
+from ....sandbox.session.dependencies import Dependencies
+from ....sandbox.session.manager import Instrumentation
+from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
+from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
+from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
+from ....sandbox.util.retry import (
+ exception_chain_contains_type,
+ exception_chain_has_status_code,
+ retry_async,
+)
+from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tarfile
+
+WorkspacePersistenceMode = Literal["tar", "snapshot"]
+
+_WORKSPACE_PERSISTENCE_TAR: WorkspacePersistenceMode = "tar"
+_WORKSPACE_PERSISTENCE_SNAPSHOT: WorkspacePersistenceMode = "snapshot"
+_VERCEL_SNAPSHOT_MAGIC = b"UC_VERCEL_SNAPSHOT_V1\n"
+DEFAULT_VERCEL_WORKSPACE_ROOT = "/vercel/sandbox"
+_DEFAULT_MANIFEST_ROOT = cast(str, Manifest.model_fields["root"].default)
+DEFAULT_VERCEL_SANDBOX_TIMEOUT_MS = 270_000
+DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S = 45.0
+_NETWORK_POLICY_ADAPTER: TypeAdapter[NetworkPolicy] = TypeAdapter(NetworkPolicy)
+
+_VERCEL_TRANSIENT_TRANSPORT_ERRORS: tuple[type[BaseException], ...] = (
+ httpx.ReadError,
+ httpx.NetworkError,
+ httpx.ProtocolError,
+)
+
+
+def _is_transient_create_error(exc: BaseException) -> bool:
+ if exception_chain_has_status_code(exc, {408, 425, 429, 500, 502, 503, 504}):
+ return True
+
+ return exception_chain_contains_type(exc, _VERCEL_TRANSIENT_TRANSPORT_ERRORS)
+
+
+def _is_transient_write_error(exc: BaseException) -> bool:
+ if exception_chain_has_status_code(exc, {408, 425, 429, 500, 502, 503, 504}):
+ return True
+
+ return exception_chain_contains_type(exc, _VERCEL_TRANSIENT_TRANSPORT_ERRORS)
+
+
+@retry_async(retry_if=lambda exc, **_kwargs: _is_transient_create_error(exc))
+async def _create_sandbox_with_retry(**kwargs):
+ return await AsyncSandbox.create(**kwargs)
+
+
+def _encode_snapshot_ref(*, snapshot_id: str) -> bytes:
+ body = json.dumps({"snapshot_id": snapshot_id}, separators=(",", ":"), sort_keys=True).encode(
+ "utf-8"
+ )
+ return _VERCEL_SNAPSHOT_MAGIC + body
+
+
+def _decode_snapshot_ref(raw: bytes) -> str | None:
+ if not raw.startswith(_VERCEL_SNAPSHOT_MAGIC):
+ return None
+
+ body = raw[len(_VERCEL_SNAPSHOT_MAGIC) :]
+ try:
+ payload = json.loads(body.decode("utf-8"))
+ except Exception:
+ return None
+
+ snapshot_id = payload.get("snapshot_id")
+ return snapshot_id if isinstance(snapshot_id, str) and snapshot_id else None
+
+
+def _resolve_manifest_root(manifest: Manifest | None) -> Manifest:
+ if manifest is None:
+ return Manifest(root=DEFAULT_VERCEL_WORKSPACE_ROOT)
+
+ if manifest.root == _DEFAULT_MANIFEST_ROOT:
+ return manifest.model_copy(update={"root": DEFAULT_VERCEL_WORKSPACE_ROOT})
+
+ root = Path(manifest.root)
+ default_root = Path(DEFAULT_VERCEL_WORKSPACE_ROOT)
+ if not root.is_absolute() or root == default_root or default_root in root.parents:
+ return manifest
+
+ raise ConfigurationError(
+ message=(
+ "Vercel sandboxes require manifest.root to stay within "
+ f"{DEFAULT_VERCEL_WORKSPACE_ROOT!r}"
+ ),
+ error_code=ErrorCode.SANDBOX_CONFIG_INVALID,
+ op="start",
+ context={"backend": "vercel", "manifest_root": manifest.root},
+ )
+
+
+def _validate_network_policy(value: object) -> NetworkPolicy | None:
+ if value is None:
+ return None
+
+ return _NETWORK_POLICY_ADAPTER.validate_python(value)
+
+
+def _serialize_network_policy(value: NetworkPolicy | None) -> object | None:
+ if value is None:
+ return None
+
+ return cast(object | None, _NETWORK_POLICY_ADAPTER.dump_python(value, mode="json"))
+
+
+class VercelSandboxClientOptions(BaseSandboxClientOptions):
+ """Client options for the Vercel sandbox backend."""
+
+ type: Literal["vercel"] = "vercel"
+ project_id: str | None = None
+ team_id: str | None = None
+ timeout_ms: int | None = DEFAULT_VERCEL_SANDBOX_TIMEOUT_MS
+ runtime: str | None = None
+ resources: dict[str, object] | None = None
+ env: dict[str, str] | None = None
+ exposed_ports: tuple[int, ...] = ()
+ interactive: bool = False
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR
+ snapshot_expiration_ms: int | None = None
+ network_policy: NetworkPolicy | None = None
+
+ def __init__(
+ self,
+ project_id: str | None = None,
+ team_id: str | None = None,
+ timeout_ms: int | None = DEFAULT_VERCEL_SANDBOX_TIMEOUT_MS,
+ runtime: str | None = None,
+ resources: dict[str, object] | None = None,
+ env: dict[str, str] | None = None,
+ exposed_ports: tuple[int, ...] = (),
+ interactive: bool = False,
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR,
+ snapshot_expiration_ms: int | None = None,
+ network_policy: NetworkPolicy | None = None,
+ *,
+ type: Literal["vercel"] = "vercel",
+ ) -> None:
+ super().__init__(
+ type=type,
+ project_id=project_id,
+ team_id=team_id,
+ timeout_ms=timeout_ms,
+ runtime=runtime,
+ resources=resources,
+ env=env,
+ exposed_ports=exposed_ports,
+ interactive=interactive,
+ workspace_persistence=workspace_persistence,
+ snapshot_expiration_ms=snapshot_expiration_ms,
+ network_policy=network_policy,
+ )
+
+ @field_validator("network_policy", mode="before")
+ @classmethod
+ def _coerce_network_policy(cls, value: object) -> NetworkPolicy | None:
+ return _validate_network_policy(value)
+
+ @field_serializer("network_policy", when_used="json")
+ def _serialize_network_policy_field(self, value: NetworkPolicy | None) -> object | None:
+ return _serialize_network_policy(value)
+
+
+class VercelSandboxSessionState(SandboxSessionState):
+ """Serializable state for a Vercel-backed session."""
+
+ type: Literal["vercel"] = "vercel"
+ sandbox_id: str
+ project_id: str | None = None
+ team_id: str | None = None
+ timeout_ms: int | None = None
+ runtime: str | None = None
+ resources: dict[str, object] | None = None
+ env: dict[str, str] | None = None
+ interactive: bool = False
+ workspace_persistence: WorkspacePersistenceMode = _WORKSPACE_PERSISTENCE_TAR
+ snapshot_expiration_ms: int | None = None
+ network_policy: NetworkPolicy | None = None
+
+ @field_validator("network_policy", mode="before")
+ @classmethod
+ def _coerce_network_policy(cls, value: object) -> NetworkPolicy | None:
+ return _validate_network_policy(value)
+
+ @field_serializer("network_policy", when_used="json")
+ def _serialize_network_policy_field(self, value: NetworkPolicy | None) -> object | None:
+ return _serialize_network_policy(value)
+
+
+class VercelSandboxSession(BaseSandboxSession):
+ """SandboxSession implementation backed by a Vercel sandbox."""
+
+ state: VercelSandboxSessionState
+ _sandbox: Any | None
+ _token: str | None
+
+ def __init__(
+ self,
+ *,
+ state: VercelSandboxSessionState,
+ sandbox: Any | None = None,
+ token: str | None = None,
+ ) -> None:
+ self.state = state
+ self._sandbox = sandbox
+ self._token = token
+
+ @classmethod
+ def from_state(
+ cls,
+ state: VercelSandboxSessionState,
+ *,
+ sandbox: Any | None = None,
+ token: str | None = None,
+ ) -> VercelSandboxSession:
+ return cls(state=state, sandbox=sandbox, token=token)
+
+ def supports_pty(self) -> bool:
+ return False
+
+ def _reject_user_arg(self, *, op: Literal["exec", "read", "write"], user: str | User) -> None:
+ user_name = user.name if isinstance(user, User) else user
+ raise ConfigurationError(
+ message=(
+ "VercelSandboxSession does not support sandbox-local users; "
+ f"`{op}` must be called without `user`"
+ ),
+ error_code=ErrorCode.SANDBOX_CONFIG_INVALID,
+ op=op,
+ context={"backend": "vercel", "user": user_name},
+ )
+
+ def _prepare_exec_command(
+ self,
+ *command: str | Path,
+ shell: bool | list[str],
+ user: str | User | None,
+ ) -> list[str]:
+ if user is not None:
+ self._reject_user_arg(op="exec", user=user)
+ return super()._prepare_exec_command(*command, shell=shell, user=user)
+
+ def normalize_path(self, path: Path | str) -> Path:
+ # Keep normalization lexical so host filesystem quirks do not rewrite sandbox paths.
+ if isinstance(path, str):
+ path = Path(path)
+
+ root = PurePosixPath(os.path.normpath(self.state.manifest.root))
+ normalized = PurePosixPath(
+ os.path.normpath(
+ str(path) if path.is_absolute() else str(root / PurePosixPath(*path.parts))
+ )
+ )
+ try:
+ normalized.relative_to(root)
+ except ValueError as exc:
+ reason: Literal["absolute", "escape_root"] = (
+ "absolute" if path.is_absolute() else "escape_root"
+ )
+ raise InvalidManifestPathError(rel=path, reason=reason, cause=exc) from exc
+ return Path(str(normalized))
+
+ async def _normalize_path_for_io(self, path: Path | str) -> Path:
+ return self.normalize_path(path)
+
+ def _validate_tar_bytes(self, raw: bytes) -> None:
+ try:
+ with tarfile.open(fileobj=io.BytesIO(raw), mode="r:*") as tar:
+ validate_tarfile(tar)
+ except UnsafeTarMemberError as exc:
+ raise ValueError(str(exc)) from exc
+ except (tarfile.TarError, OSError) as exc:
+ raise ValueError("invalid tar stream") from exc
+
+ async def _ensure_workspace_root(self) -> None:
+ root = Path(self.state.manifest.root)
+ sandbox = await self._ensure_sandbox()
+ try:
+ finished = await sandbox.run_command("mkdir", ["-p", "--", root.as_posix()])
+ except Exception as exc:
+ raise WorkspaceStartError(path=root, cause=exc) from exc
+ if finished.exit_code != 0:
+ raise WorkspaceStartError(
+ path=root,
+ context={
+ "exit_code": finished.exit_code,
+ "stdout": await finished.stdout(),
+ "stderr": await finished.stderr(),
+ },
+ )
+ try:
+ finished = await sandbox.run_command("test", ["-d", root.as_posix()])
+ except Exception as exc:
+ raise WorkspaceStartError(path=root, cause=exc) from exc
+ if finished.exit_code != 0:
+ raise WorkspaceStartError(
+ path=root,
+ context={
+ "exit_code": finished.exit_code,
+ "stdout": await finished.stdout(),
+ "stderr": await finished.stderr(),
+ },
+ )
+
+ async def start(self) -> None:
+ try:
+ await self._ensure_workspace_root()
+ except WorkspaceStartError:
+ raise
+ except Exception as exc:
+ raise WorkspaceStartError(path=Path(self.state.manifest.root), cause=exc) from exc
+ await super().start()
+
+ async def _ensure_sandbox(self, *, source: Any | None = None) -> Any:
+ sandbox = self._sandbox
+ if sandbox is not None:
+ return sandbox
+
+ manifest_env = cast(dict[str, str | None], await self.state.manifest.environment.resolve())
+ env = {
+ key: value
+ for key, value in {**(self.state.env or {}), **manifest_env}.items()
+ if value is not None
+ }
+ sandbox = await _create_sandbox_with_retry(
+ source=source,
+ ports=list(self.state.exposed_ports) or None,
+ timeout=self.state.timeout_ms,
+ resources=(
+ Resources.model_validate(self.state.resources)
+ if self.state.resources is not None
+ else None
+ ),
+ runtime=self.state.runtime,
+ token=self._token,
+ project_id=self.state.project_id,
+ team_id=self.state.team_id,
+ interactive=self.state.interactive,
+ env=env or None,
+ network_policy=self.state.network_policy,
+ )
+ await sandbox.wait_for_status(
+ SandboxStatus.RUNNING,
+ timeout=DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S,
+ )
+ self._sandbox = sandbox
+ self.state.sandbox_id = sandbox.sandbox_id
+ return sandbox
+
+ async def _close_sandbox_client(self) -> None:
+ sandbox = self._sandbox
+ if sandbox is None:
+ return
+ try:
+ await sandbox.client.aclose()
+ except Exception:
+ return
+
+ async def _stop_attached_sandbox(self) -> None:
+ sandbox = self._sandbox
+ if sandbox is None:
+ return
+ try:
+ await sandbox.stop()
+ except Exception:
+ pass
+ finally:
+ await self._close_sandbox_client()
+ self._sandbox = None
+
+ async def _replace_sandbox_from_snapshot(self, snapshot_id: str) -> None:
+ await self._stop_attached_sandbox()
+ await self._ensure_sandbox(source=SnapshotSource(snapshot_id=snapshot_id))
+
+ async def _restore_snapshot_reference_id(self, snapshot: SnapshotBase) -> str | None:
+ if not await snapshot.restorable():
+ return None
+ restored = await snapshot.restore()
+ try:
+ raw = restored.read()
+ finally:
+ try:
+ restored.close()
+ except Exception:
+ pass
+
+ if isinstance(raw, str):
+ raw = raw.encode("utf-8")
+ if not isinstance(raw, bytes | bytearray):
+ return None
+ return _decode_snapshot_ref(bytes(raw))
+
+ async def running(self) -> bool:
+ sandbox = self._sandbox
+ if sandbox is None:
+ return False
+ try:
+ await sandbox.refresh()
+ except Exception:
+ return False
+ return bool(sandbox.status == SandboxStatus.RUNNING)
+
+ async def shutdown(self) -> None:
+ await self._stop_attached_sandbox()
+
+ async def _persist_with_ephemeral_mounts_removed(
+ self,
+ operation: Callable[[], Awaitable[io.IOBase]],
+ ) -> io.IOBase:
+ root = Path(self.state.manifest.root)
+ unmounted_mounts: list[tuple[Any, Path]] = []
+ unmount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as exc:
+ unmount_error = WorkspaceArchiveReadError(path=root, cause=exc)
+ break
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ persist_error: WorkspaceArchiveReadError | None = None
+ persisted: io.IOBase | None = None
+ if unmount_error is None:
+ try:
+ persisted = await operation()
+ except WorkspaceArchiveReadError as exc:
+ persist_error = exc
+
+ remount_error: WorkspaceArchiveReadError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as exc:
+ if remount_error is None:
+ remount_error = WorkspaceArchiveReadError(path=root, cause=exc)
+
+ if remount_error is not None:
+ if persist_error is not None:
+ remount_error.context["snapshot_error_before_remount_corruption"] = {
+ "message": persist_error.message
+ }
+ raise remount_error
+ if unmount_error is not None:
+ raise unmount_error
+ if persist_error is not None:
+ raise persist_error
+
+ assert persisted is not None
+ return persisted
+
+ async def _hydrate_with_ephemeral_mounts_removed(
+ self,
+ operation: Callable[[], Awaitable[None]],
+ ) -> None:
+ root = Path(self.state.manifest.root)
+ unmounted_mounts: list[tuple[Any, Path]] = []
+ unmount_error: WorkspaceArchiveWriteError | None = None
+ for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
+ try:
+ await mount_entry.mount_strategy.teardown_for_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as exc:
+ unmount_error = WorkspaceArchiveWriteError(path=root, cause=exc)
+ break
+ unmounted_mounts.append((mount_entry, mount_path))
+
+ hydrate_error: WorkspaceArchiveWriteError | None = None
+ if unmount_error is None:
+ try:
+ await operation()
+ except WorkspaceArchiveWriteError as exc:
+ hydrate_error = exc
+
+ remount_error: WorkspaceArchiveWriteError | None = None
+ for mount_entry, mount_path in reversed(unmounted_mounts):
+ try:
+ await mount_entry.mount_strategy.restore_after_snapshot(
+ mount_entry, self, mount_path
+ )
+ except Exception as exc:
+ if remount_error is None:
+ remount_error = WorkspaceArchiveWriteError(path=root, cause=exc)
+
+ if remount_error is not None:
+ if hydrate_error is not None:
+ remount_error.context["hydrate_error_before_remount_corruption"] = {
+ "message": hydrate_error.message
+ }
+ raise remount_error
+ if unmount_error is not None:
+ raise unmount_error
+ if hydrate_error is not None:
+ raise hydrate_error
+
+ async def _exec_internal(
+ self,
+ *command: str | Path,
+ timeout: float | None = None,
+ ) -> ExecResult:
+ sandbox = await self._ensure_sandbox()
+ normalized = [str(part) for part in command]
+ if not normalized:
+ return ExecResult(stdout=b"", stderr=b"", exit_code=0)
+
+ try:
+ finished = await asyncio.wait_for(
+ sandbox.run_command(
+ normalized[0],
+ normalized[1:],
+ cwd=self.state.manifest.root,
+ ),
+ timeout=timeout,
+ )
+ stdout = (await finished.stdout()).encode("utf-8")
+ stderr = (await finished.stderr()).encode("utf-8")
+ return ExecResult(stdout=stdout, stderr=stderr, exit_code=finished.exit_code)
+ except TimeoutError as exc:
+ raise ExecTimeoutError(command=normalized, timeout_s=timeout, cause=exc) from exc
+ except ExecTimeoutError:
+ raise
+ except Exception as exc:
+ raise ExecTransportError(
+ command=normalized,
+ context={"backend": "vercel", "sandbox_id": self.state.sandbox_id},
+ cause=exc,
+ ) from exc
+
+ async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
+ sandbox = await self._ensure_sandbox()
+ try:
+ domain = sandbox.domain(port)
+ except Exception as exc:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "vercel", "sandbox_id": self.state.sandbox_id},
+ cause=exc,
+ ) from exc
+
+ parsed = urlsplit(domain)
+ host = parsed.hostname
+ if not host:
+ raise ExposedPortUnavailableError(
+ port=port,
+ exposed_ports=self.state.exposed_ports,
+ reason="backend_unavailable",
+ context={"backend": "vercel", "domain": domain},
+ )
+ tls = parsed.scheme == "https"
+ return ExposedPortEndpoint(
+ host=host,
+ port=parsed.port or (443 if tls else 80),
+ tls=tls,
+ )
+
+ async def read(self, path: Path, *, user: str | User | None = None) -> io.IOBase:
+ if user is not None:
+ self._reject_user_arg(op="read", user=user)
+
+ sandbox = await self._ensure_sandbox()
+ normalized_path = await self._normalize_path_for_io(path)
+ try:
+ payload = await sandbox.read_file(str(normalized_path))
+ except Exception as exc:
+ raise WorkspaceArchiveReadError(path=normalized_path, cause=exc) from exc
+ if payload is None:
+ raise WorkspaceReadNotFoundError(path=normalized_path)
+ return io.BytesIO(payload)
+
+ async def write(
+ self,
+ path: Path,
+ data: io.IOBase,
+ *,
+ user: str | User | None = None,
+ ) -> None:
+ if user is not None:
+ self._reject_user_arg(op="write", user=user)
+
+ normalized_path = await self._normalize_path_for_io(path)
+ payload = data.read()
+ if isinstance(payload, str):
+ payload = payload.encode("utf-8")
+ if not isinstance(payload, bytes | bytearray):
+ raise WorkspaceWriteTypeError(
+ path=normalized_path,
+ actual_type=type(payload).__name__,
+ )
+ try:
+ await self._write_files_with_retry(
+ [{"path": str(normalized_path), "content": bytes(payload)}]
+ )
+ except Exception as exc:
+ raise WorkspaceArchiveWriteError(path=normalized_path, cause=exc) from exc
+
+ async def persist_workspace(self) -> io.IOBase:
+ return await self._persist_with_ephemeral_mounts_removed(self._persist_workspace_internal)
+
+ async def _persist_workspace_internal(self) -> io.IOBase:
+ if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT:
+ root = Path(self.state.manifest.root)
+ sandbox = await self._ensure_sandbox()
+ try:
+ snapshot = await sandbox.snapshot(expiration=self.state.snapshot_expiration_ms)
+ except Exception as exc:
+ raise WorkspaceArchiveReadError(path=root, cause=exc) from exc
+ return io.BytesIO(_encode_snapshot_ref(snapshot_id=snapshot.snapshot_id))
+
+ root = Path(self.state.manifest.root)
+ sandbox = await self._ensure_sandbox()
+ archive_path = Path("/tmp") / f"openai-agents-{self.state.session_id.hex}.tar"
+ excludes = [
+ f"--exclude=./{rel_path.as_posix()}"
+ for rel_path in sorted(
+ self._persist_workspace_skip_relpaths(),
+ key=lambda item: item.as_posix(),
+ )
+ ]
+ tar_command = ("tar", "cf", str(archive_path), *excludes, ".")
+ try:
+ result = await self.exec(*tar_command, shell=False)
+ if not result.ok():
+ raise WorkspaceArchiveReadError(
+ path=root,
+ cause=ExecNonZeroError(
+ result,
+ command=tar_command,
+ context={"backend": "vercel", "sandbox_id": self.state.sandbox_id},
+ ),
+ )
+ archive = await sandbox.read_file(str(archive_path))
+ if archive is None:
+ raise WorkspaceReadNotFoundError(path=archive_path)
+ return io.BytesIO(archive)
+ except WorkspaceReadNotFoundError:
+ raise
+ except WorkspaceArchiveReadError:
+ raise
+ except Exception as exc:
+ raise WorkspaceArchiveReadError(path=root, cause=exc) from exc
+ finally:
+ try:
+ await sandbox.run_command("rm", [str(archive_path)], cwd=self.state.manifest.root)
+ except Exception:
+ pass
+
+ async def hydrate_workspace(self, data: io.IOBase) -> None:
+ raw = data.read()
+ if isinstance(raw, str):
+ raw = raw.encode("utf-8")
+ if not isinstance(raw, bytes | bytearray):
+ raise WorkspaceWriteTypeError(
+ path=Path(self.state.manifest.root),
+ actual_type=type(raw).__name__,
+ )
+
+ await self._hydrate_with_ephemeral_mounts_removed(
+ lambda: self._hydrate_workspace_internal(bytes(raw))
+ )
+
+ async def _hydrate_workspace_internal(self, raw: bytes) -> None:
+ snapshot_id = (
+ _decode_snapshot_ref(raw)
+ if self.state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT
+ else None
+ )
+ if snapshot_id is not None:
+ try:
+ await self._replace_sandbox_from_snapshot(snapshot_id)
+ except Exception as exc:
+ raise WorkspaceArchiveWriteError(
+ path=Path(self.state.manifest.root),
+ cause=exc,
+ ) from exc
+ return
+
+ root = Path(self.state.manifest.root)
+ sandbox = await self._ensure_sandbox()
+ archive_path = Path("/tmp") / f"openai-agents-{self.state.session_id.hex}.tar"
+ tar_command = ("tar", "xf", str(archive_path), "-C", str(root))
+ try:
+ self._validate_tar_bytes(raw)
+ await self.mkdir(root, parents=True)
+ await self._write_files_with_retry([{"path": str(archive_path), "content": raw}])
+ result = await self.exec(*tar_command, shell=False)
+ if not result.ok():
+ raise WorkspaceArchiveWriteError(
+ path=root,
+ cause=ExecNonZeroError(
+ result,
+ command=tar_command,
+ context={"backend": "vercel", "sandbox_id": self.state.sandbox_id},
+ ),
+ )
+ except WorkspaceArchiveWriteError:
+ raise
+ except Exception as exc:
+ raise WorkspaceArchiveWriteError(path=root, cause=exc) from exc
+ finally:
+ try:
+ await sandbox.run_command("rm", [str(archive_path)], cwd=self.state.manifest.root)
+ except Exception:
+ pass
+
+ @retry_async(
+ retry_if=lambda exc, self, _files: _is_transient_write_error(exc),
+ )
+ async def _write_files_with_retry(self, files: list[dict[str, object]]) -> None:
+ sandbox = await self._ensure_sandbox()
+ await sandbox.write_files(files)
+
+
+class VercelSandboxClient(BaseSandboxClient[VercelSandboxClientOptions]):
+ """Vercel-backed sandbox client."""
+
+ backend_id = "vercel"
+ _instrumentation: Instrumentation
+ _token: str | None
+ _project_id: str | None
+ _team_id: str | None
+
+ def __init__(
+ self,
+ *,
+ token: str | None = None,
+ project_id: str | None = None,
+ team_id: str | None = None,
+ instrumentation: Instrumentation | None = None,
+ dependencies: Dependencies | None = None,
+ ) -> None:
+ super().__init__()
+ self._token = token
+ self._project_id = project_id
+ self._team_id = team_id
+ self._instrumentation = instrumentation or Instrumentation()
+ self._dependencies = dependencies
+
+ async def create(
+ self,
+ *,
+ snapshot: SnapshotSpec | SnapshotBase | None = None,
+ manifest: Manifest | None = None,
+ options: VercelSandboxClientOptions,
+ ) -> SandboxSession:
+ resolved_manifest = _resolve_manifest_root(manifest)
+ resolved_token = self._token
+ resolved_project_id = options.project_id or self._project_id
+ resolved_team_id = options.team_id or self._team_id
+ if self._project_id is None and resolved_project_id is not None:
+ self._project_id = resolved_project_id
+ if self._team_id is None and resolved_team_id is not None:
+ self._team_id = resolved_team_id
+ session_id = uuid.uuid4()
+ snapshot_instance = resolve_snapshot(snapshot, str(session_id))
+ state = VercelSandboxSessionState(
+ session_id=session_id,
+ manifest=resolved_manifest,
+ snapshot=snapshot_instance,
+ sandbox_id="",
+ project_id=resolved_project_id,
+ team_id=resolved_team_id,
+ timeout_ms=options.timeout_ms,
+ runtime=options.runtime,
+ resources=options.resources,
+ env=dict(options.env or {}) or None,
+ exposed_ports=options.exposed_ports,
+ interactive=options.interactive,
+ workspace_persistence=options.workspace_persistence,
+ snapshot_expiration_ms=options.snapshot_expiration_ms,
+ network_policy=options.network_policy,
+ )
+ inner = VercelSandboxSession.from_state(state, token=resolved_token)
+ await inner._ensure_sandbox()
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ async def delete(self, session: SandboxSession) -> SandboxSession:
+ inner = session._inner
+ if not isinstance(inner, VercelSandboxSession):
+ raise TypeError("VercelSandboxClient.delete expects a VercelSandboxSession")
+ try:
+ await inner.shutdown()
+ except Exception:
+ pass
+ return session
+
+ async def resume(self, state: SandboxSessionState) -> SandboxSession:
+ if not isinstance(state, VercelSandboxSessionState):
+ raise TypeError("VercelSandboxClient.resume expects a VercelSandboxSessionState")
+
+ resolved_token = self._token
+ resolved_project_id = state.project_id or self._project_id
+ resolved_team_id = state.team_id or self._team_id
+ if state.project_id is None:
+ state.project_id = resolved_project_id
+ if state.team_id is None:
+ state.team_id = resolved_team_id
+
+ snapshot_id: str | None = None
+ if state.workspace_persistence == _WORKSPACE_PERSISTENCE_SNAPSHOT:
+ probe = VercelSandboxSession.from_state(state, token=resolved_token)
+ snapshot_id = await probe._restore_snapshot_reference_id(state.snapshot)
+
+ if snapshot_id is not None:
+ inner = VercelSandboxSession.from_state(state, token=resolved_token)
+ await inner._ensure_sandbox(source=SnapshotSource(snapshot_id=snapshot_id))
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ sandbox = None
+ reconnected = False
+ if state.sandbox_id:
+ try:
+ sandbox = await AsyncSandbox.get(
+ sandbox_id=state.sandbox_id,
+ token=resolved_token,
+ project_id=resolved_project_id,
+ team_id=resolved_team_id,
+ )
+ # XXX(scotttrinh): This will wait even if in a terminal state.
+ # We should make wait_for_status smarter about the possible
+ # transitions to avoid waiting for a status if it's impossible
+ # to transition to it from the current status.
+ await sandbox.wait_for_status(
+ SandboxStatus.RUNNING,
+ timeout=DEFAULT_VERCEL_WAIT_FOR_RUNNING_TIMEOUT_S,
+ )
+ reconnected = True
+ except TimeoutError:
+ if sandbox is not None:
+ await sandbox.client.aclose()
+ sandbox = None
+ except Exception:
+ sandbox = None
+
+ inner = VercelSandboxSession.from_state(state, sandbox=sandbox, token=resolved_token)
+ if sandbox is None:
+ state.workspace_root_ready = False
+ await inner._ensure_sandbox()
+ inner._set_start_state_preserved(reconnected)
+ return self._wrap_session(inner, instrumentation=self._instrumentation)
+
+ def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
+ return VercelSandboxSessionState.model_validate(payload)
+
+
+__all__ = [
+ "VercelSandboxClient",
+ "VercelSandboxClientOptions",
+ "VercelSandboxSession",
+ "VercelSandboxSessionState",
+]
diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py
index 881ebdf0..8fe52df3 100644
--- a/src/agents/function_schema.py
+++ b/src/agents/function_schema.py
@@ -4,8 +4,9 @@ import contextlib
import inspect
import logging
import re
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import Annotated, Any, Callable, Literal, get_args, get_origin, get_type_hints
+from typing import Annotated, Any, Literal, get_args, get_origin, get_type_hints
# griffelib exposes the `griffe` package at runtime but currently does not ship typing markers.
from griffe import Docstring, DocstringSectionKind # type: ignore[import-untyped]
diff --git a/src/agents/guardrail.py b/src/agents/guardrail.py
index 8ab68cd3..7f5061c8 100644
--- a/src/agents/guardrail.py
+++ b/src/agents/guardrail.py
@@ -1,9 +1,9 @@
from __future__ import annotations
import inspect
-from collections.abc import Awaitable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Callable, Generic, Union, overload
+from typing import TYPE_CHECKING, Any, Generic, overload
from typing_extensions import TypeVar
@@ -189,11 +189,11 @@ TContext_co = TypeVar("TContext_co", bound=Any, covariant=True)
# For InputGuardrail
_InputGuardrailFuncSync = Callable[
- [RunContextWrapper[TContext_co], "Agent[Any]", Union[str, list[TResponseInputItem]]],
+ [RunContextWrapper[TContext_co], "Agent[Any]", str | list[TResponseInputItem]],
GuardrailFunctionOutput,
]
_InputGuardrailFuncAsync = Callable[
- [RunContextWrapper[TContext_co], "Agent[Any]", Union[str, list[TResponseInputItem]]],
+ [RunContextWrapper[TContext_co], "Agent[Any]", str | list[TResponseInputItem]],
Awaitable[GuardrailFunctionOutput],
]
diff --git a/src/agents/handoffs/__init__.py b/src/agents/handoffs/__init__.py
index cea4a0cd..b9ac7d3d 100644
--- a/src/agents/handoffs/__init__.py
+++ b/src/agents/handoffs/__init__.py
@@ -3,12 +3,12 @@ from __future__ import annotations
import inspect
import json
import weakref
-from collections.abc import Awaitable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field, replace as dataclasses_replace
-from typing import TYPE_CHECKING, Any, Callable, Generic, cast, overload
+from typing import TYPE_CHECKING, Any, Generic, TypeAlias, cast, overload
from pydantic import TypeAdapter
-from typing_extensions import TypeAlias, TypeVar
+from typing_extensions import TypeVar
from ..exceptions import ModelBehaviorError, UserError
from ..items import RunItem, TResponseInputItem
diff --git a/src/agents/items.py b/src/agents/items.py
index 9d6219f3..6db6c5c5 100644
--- a/src/agents/items.py
+++ b/src/agents/items.py
@@ -5,7 +5,7 @@ import json
import weakref
from collections.abc import Mapping
from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union, cast
+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, TypeVar, cast
import pydantic
from openai.types.responses import (
@@ -48,7 +48,7 @@ from openai.types.responses.response_output_item import (
)
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
from pydantic import BaseModel
-from typing_extensions import TypeAlias, assert_never
+from typing_extensions import assert_never
from ._tool_identity import FunctionToolLookupKey, get_function_tool_lookup_key, tool_trace_name
from .exceptions import AgentsException, ModelBehaviorError
@@ -78,7 +78,7 @@ TResponseOutputItem = ResponseOutputItem
TResponseStreamEvent = ResponseStreamEvent
"""A type alias for the ResponseStreamEvent type from the OpenAI SDK."""
-T = TypeVar("T", bound=Union[TResponseOutputItem, TResponseInputItem, dict[str, Any]])
+T = TypeVar("T", bound=TResponseOutputItem | TResponseInputItem | dict[str, Any])
ToolSearchCallRawItem: TypeAlias = ResponseToolSearchCall | dict[str, Any]
ToolSearchOutputRawItem: TypeAlias = ResponseToolSearchOutputItem | dict[str, Any]
@@ -329,17 +329,17 @@ class HandoffOutputItem(RunItemBase[TResponseInputItem]):
self.__dict__["target_agent"] = None
-ToolCallItemTypes: TypeAlias = Union[
- ResponseFunctionToolCall,
- ResponseComputerToolCall,
- ResponseFileSearchToolCall,
- ResponseFunctionWebSearch,
- McpCall,
- ResponseCodeInterpreterToolCall,
- ImageGenerationCall,
- LocalShellCall,
- dict[str, Any],
-]
+ToolCallItemTypes: TypeAlias = (
+ ResponseFunctionToolCall
+ | ResponseComputerToolCall
+ | ResponseFileSearchToolCall
+ | ResponseFunctionWebSearch
+ | McpCall
+ | ResponseCodeInterpreterToolCall
+ | ImageGenerationCall
+ | LocalShellCall
+ | dict[str, Any]
+)
"""A type that represents a tool call item."""
@@ -359,13 +359,13 @@ class ToolCallItem(RunItemBase[Any]):
"""Optional short display label if known at item creation time."""
-ToolCallOutputTypes: TypeAlias = Union[
- FunctionCallOutput,
- ComputerCallOutput,
- LocalShellCallOutput,
- ResponseFunctionShellToolCallOutput,
- dict[str, Any],
-]
+ToolCallOutputTypes: TypeAlias = (
+ FunctionCallOutput
+ | ComputerCallOutput
+ | LocalShellCallOutput
+ | ResponseFunctionShellToolCallOutput
+ | dict[str, Any]
+)
@dataclass
@@ -464,13 +464,9 @@ class CompactionItem(RunItemBase[TResponseInputItem]):
# Union type for tool approval raw items - supports function tools, hosted tools, shell tools, etc.
-ToolApprovalRawItem: TypeAlias = Union[
- ResponseFunctionToolCall,
- McpCall,
- McpApprovalRequest,
- LocalShellCall,
- dict[str, Any], # For flexibility with other tool types
-]
+ToolApprovalRawItem: TypeAlias = (
+ ResponseFunctionToolCall | McpCall | McpApprovalRequest | LocalShellCall | dict[str, Any]
+)
@dataclass
@@ -601,21 +597,21 @@ class ToolApprovalItem(RunItemBase[Any]):
)
-RunItem: TypeAlias = Union[
- MessageOutputItem,
- ToolSearchCallItem,
- ToolSearchOutputItem,
- HandoffCallItem,
- HandoffOutputItem,
- ToolCallItem,
- ToolCallOutputItem,
- ReasoningItem,
- MCPListToolsItem,
- MCPApprovalRequestItem,
- MCPApprovalResponseItem,
- CompactionItem,
- ToolApprovalItem,
-]
+RunItem: TypeAlias = (
+ MessageOutputItem
+ | ToolSearchCallItem
+ | ToolSearchOutputItem
+ | HandoffCallItem
+ | HandoffOutputItem
+ | ToolCallItem
+ | ToolCallOutputItem
+ | ReasoningItem
+ | MCPListToolsItem
+ | MCPApprovalRequestItem
+ | MCPApprovalResponseItem
+ | CompactionItem
+ | ToolApprovalItem
+)
"""An item generated by an agent."""
@@ -744,7 +740,7 @@ class ItemHelpers:
# If the output is either a single or list of the known structured output types, convert to
# ResponseFunctionCallOutputItemListParam. Else, just stringify.
- if isinstance(output, (list, tuple)):
+ if isinstance(output, list | tuple):
maybe_converted_output_list = [
cls._maybe_get_output_as_structured_function_output(item) for item in output
]
@@ -767,7 +763,7 @@ class ItemHelpers:
def _maybe_get_output_as_structured_function_output(
cls, output: Any
) -> ValidToolOutputPydanticModels | None:
- if isinstance(output, (ToolOutputText, ToolOutputImage, ToolOutputFileContent)):
+ if isinstance(output, ToolOutputText | ToolOutputImage | ToolOutputFileContent):
return output
elif isinstance(output, dict):
# Require explicit 'type' field in dict to be considered a structured output
diff --git a/src/agents/lifecycle.py b/src/agents/lifecycle.py
index 38744471..e10ca7cc 100644
--- a/src/agents/lifecycle.py
+++ b/src/agents/lifecycle.py
@@ -1,4 +1,4 @@
-from typing import Any, Generic, Optional
+from typing import Any, Generic
from typing_extensions import TypeVar
@@ -19,7 +19,7 @@ class RunHooksBase(Generic[TContext, TAgent]):
self,
context: RunContextWrapper[TContext],
agent: Agent[TContext],
- system_prompt: Optional[str],
+ system_prompt: str | None,
input_items: list[TResponseInputItem],
) -> None:
"""Called just before invoking the LLM for this agent."""
@@ -152,7 +152,7 @@ class AgentHooksBase(Generic[TContext, TAgent]):
self,
context: RunContextWrapper[TContext],
agent: Agent[TContext],
- system_prompt: Optional[str],
+ system_prompt: str | None,
input_items: list[TResponseInputItem],
) -> None:
"""Called immediately before the agent issues an LLM call."""
diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py
index b8c7a69d..51b81bd0 100644
--- a/src/agents/mcp/server.py
+++ b/src/agents/mcp/server.py
@@ -4,11 +4,11 @@ import abc
import asyncio
import inspect
import sys
-from collections.abc import AsyncGenerator, Awaitable
+from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from datetime import timedelta
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, Union, cast
+from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, cast
import anyio
import httpx
@@ -662,14 +662,14 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC):
def _extract_http_error_from_exception(self, e: BaseException) -> Exception | None:
"""Extract HTTP error from exception or ExceptionGroup."""
- if isinstance(e, (httpx.HTTPStatusError, httpx.ConnectError, httpx.TimeoutException)):
+ if isinstance(e, httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException):
return e
# Check if it's an ExceptionGroup containing HTTP errors
if isinstance(e, BaseExceptionGroup):
for exc in e.exceptions:
if isinstance(
- exc, (httpx.HTTPStatusError, httpx.ConnectError, httpx.TimeoutException)
+ exc, httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException
):
return exc
@@ -739,7 +739,7 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC):
raise
# For HTTP-related errors, wrap them
- if isinstance(e, (httpx.HTTPStatusError, httpx.ConnectError, httpx.TimeoutException)):
+ if isinstance(e, httpx.HTTPStatusError | httpx.ConnectError | httpx.TimeoutException):
self._raise_user_error_for_http_error(e)
# For other errors, re-raise as-is (don't wrap non-HTTP errors)
@@ -1432,12 +1432,10 @@ class MCPServerStreamableHttp(_MCPServerWithClientSession):
def _should_retry_in_isolated_session(self, exc: BaseException) -> bool:
if isinstance(
exc,
- (
- asyncio.CancelledError,
- ClosedResourceError,
- httpx.ConnectError,
- httpx.TimeoutException,
- ),
+ asyncio.CancelledError
+ | ClosedResourceError
+ | httpx.ConnectError
+ | httpx.TimeoutException,
):
return True
if isinstance(exc, httpx.HTTPStatusError):
diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py
index 33bea065..7ab26e7e 100644
--- a/src/agents/mcp/util.py
+++ b/src/agents/mcp/util.py
@@ -5,9 +5,9 @@ import copy
import functools
import inspect
import json
-from collections.abc import Awaitable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Callable, Protocol, Union
+from typing import TYPE_CHECKING, Any, Protocol, Union
import httpx
from typing_extensions import NotRequired, TypedDict
@@ -33,6 +33,7 @@ from ..tool import (
_build_wrapped_function_tool,
default_tool_error_function,
)
+from ..tool_context import ToolContext
from ..tracing import FunctionSpanData, get_current_span, mcp_tools_span
from ..util._types import MaybeAwaitable
@@ -466,7 +467,10 @@ class MCPUtil:
current_span = get_current_span()
if current_span:
if isinstance(current_span.span_data, FunctionSpanData):
- current_span.span_data.output = tool_output
+ if not isinstance(context, ToolContext) or (
+ context.run_config is None or context.run_config.trace_include_sensitive_data
+ ):
+ current_span.span_data.output = tool_output
current_span.span_data.mcp_data = {
"server": server.name,
}
diff --git a/src/agents/memory/openai_responses_compaction_session.py b/src/agents/memory/openai_responses_compaction_session.py
index 4f8fbb37..8b2e170f 100644
--- a/src/agents/memory/openai_responses_compaction_session.py
+++ b/src/agents/memory/openai_responses_compaction_session.py
@@ -1,7 +1,8 @@
from __future__ import annotations
import logging
-from typing import TYPE_CHECKING, Any, Callable, Literal
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any, Literal
from openai import AsyncOpenAI
diff --git a/src/agents/memory/session.py b/src/agents/memory/session.py
index 85a65a16..1781b7ac 100644
--- a/src/agents/memory/session.py
+++ b/src/agents/memory/session.py
@@ -1,9 +1,9 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
+from typing import TYPE_CHECKING, Literal, Protocol, TypeGuard, runtime_checkable
-from typing_extensions import TypedDict, TypeGuard
+from typing_extensions import TypedDict
if TYPE_CHECKING:
from ..items import TResponseInputItem
diff --git a/src/agents/memory/util.py b/src/agents/memory/util.py
index 49f28115..5140e461 100644
--- a/src/agents/memory/util.py
+++ b/src/agents/memory/util.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import Callable
+from collections.abc import Callable
from ..items import TResponseInputItem
from ..util._types import MaybeAwaitable
diff --git a/src/agents/model_settings.py b/src/agents/model_settings.py
index 55f36289..cb8c388b 100644
--- a/src/agents/model_settings.py
+++ b/src/agents/model_settings.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import fields, replace
-from typing import Annotated, Any, Literal, Union, cast
+from typing import Annotated, Any, Literal, TypeAlias, cast
from openai import Omit as _Omit
from openai._types import Body, Query
@@ -11,7 +11,6 @@ from openai.types.shared import Reasoning
from pydantic import GetCoreSchemaHandler, TypeAdapter
from pydantic.dataclasses import dataclass
from pydantic_core import core_schema
-from typing_extensions import TypeAlias
from .retry import (
ModelRetryBackoffInput,
@@ -57,8 +56,8 @@ class MCPToolChoice:
Omit = Annotated[_Omit, _OmitTypeAnnotation]
-Headers: TypeAlias = Mapping[str, Union[str, Omit]]
-ToolChoice: TypeAlias = Union[Literal["auto", "required", "none"], str, MCPToolChoice, None]
+Headers: TypeAlias = Mapping[str, str | Omit]
+ToolChoice: TypeAlias = Literal["auto", "required", "none"] | str | MCPToolChoice | None
@dataclass
diff --git a/src/agents/models/__init__.py b/src/agents/models/__init__.py
index 82998ac5..410be93e 100644
--- a/src/agents/models/__init__.py
+++ b/src/agents/models/__init__.py
@@ -4,10 +4,12 @@ from .default_models import (
gpt_5_reasoning_settings_required,
is_gpt_5_default,
)
+from .openai_agent_registration import OpenAIAgentRegistrationConfig
__all__ = [
"get_default_model",
"get_default_model_settings",
"gpt_5_reasoning_settings_required",
"is_gpt_5_default",
+ "OpenAIAgentRegistrationConfig",
]
diff --git a/src/agents/models/chatcmpl_converter.py b/src/agents/models/chatcmpl_converter.py
index 60fa10b6..3a959fbe 100644
--- a/src/agents/models/chatcmpl_converter.py
+++ b/src/agents/models/chatcmpl_converter.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from collections.abc import Iterable
-from typing import Any, Literal, Union, cast
+from typing import Any, Literal, cast
from openai import Omit, omit
from openai.types.chat import (
@@ -62,11 +62,9 @@ from .reasoning_content_replay import (
default_should_replay_reasoning_content,
)
-ResponseInputContentWithAudioParam = Union[
- ResponseInputContentParam,
- ResponseInputAudioParam,
- dict[str, Any],
-]
+ResponseInputContentWithAudioParam = (
+ ResponseInputContentParam | ResponseInputAudioParam | dict[str, Any]
+)
class Converter:
@@ -732,7 +730,7 @@ class Converter:
elif func_output := cls.maybe_function_tool_call_output(item):
flush_assistant_message()
output_content = cast(
- Union[str, Iterable[ResponseInputContentWithAudioParam]], func_output["output"]
+ str | Iterable[ResponseInputContentWithAudioParam], func_output["output"]
)
if preserve_tool_output_all_content:
tool_result_content = cls.extract_all_content(output_content)
diff --git a/src/agents/models/chatcmpl_helpers.py b/src/agents/models/chatcmpl_helpers.py
index 44c8ba91..487de8f3 100644
--- a/src/agents/models/chatcmpl_helpers.py
+++ b/src/agents/models/chatcmpl_helpers.py
@@ -12,6 +12,7 @@ from openai.types.responses.response_text_delta_event import (
from ..model_settings import ModelSettings
from ..version import __version__
+from .openai_client_utils import is_official_openai_client
_USER_AGENT = f"Agents/Python {__version__}"
HEADERS = {"User-Agent": _USER_AGENT}
@@ -23,8 +24,8 @@ HEADERS_OVERRIDE: ContextVar[dict[str, str] | None] = ContextVar(
class ChatCmplHelpers:
@classmethod
- def is_openai(cls, client: AsyncOpenAI):
- return str(client.base_url).startswith("https://api.openai.com")
+ def is_openai(cls, client: AsyncOpenAI) -> bool:
+ return is_official_openai_client(client)
@classmethod
def get_store_param(cls, client: AsyncOpenAI, model_settings: ModelSettings) -> bool | None:
diff --git a/src/agents/models/default_models.py b/src/agents/models/default_models.py
index d869945e..455aec27 100644
--- a/src/agents/models/default_models.py
+++ b/src/agents/models/default_models.py
@@ -1,7 +1,7 @@
import copy
import os
import re
-from typing import Literal, Optional
+from typing import Literal
from openai.types.shared.reasoning import Reasoning
@@ -98,7 +98,7 @@ def get_default_model() -> str:
return os.getenv(OPENAI_DEFAULT_MODEL_ENV_VARIABLE_NAME, "gpt-4.1").lower()
-def get_default_model_settings(model: Optional[str] = None) -> ModelSettings:
+def get_default_model_settings(model: str | None = None) -> ModelSettings:
"""
Returns the default model settings.
If the default model is a GPT-5 model, returns the GPT-5 default model settings.
diff --git a/src/agents/models/multi_provider.py b/src/agents/models/multi_provider.py
index dc9087c4..57df0814 100644
--- a/src/agents/models/multi_provider.py
+++ b/src/agents/models/multi_provider.py
@@ -6,6 +6,7 @@ from openai import AsyncOpenAI
from ..exceptions import UserError
from .interface import Model, ModelProvider
+from .openai_agent_registration import OpenAIAgentRegistrationConfig
from .openai_provider import OpenAIProvider
MultiProviderOpenAIPrefixMode = Literal["alias", "model_id"]
@@ -84,6 +85,7 @@ class MultiProvider(ModelProvider):
openai_websocket_base_url: str | None = None,
openai_prefix_mode: MultiProviderOpenAIPrefixMode = "alias",
unknown_prefix_mode: MultiProviderUnknownPrefixMode = "error",
+ openai_agent_registration: OpenAIAgentRegistrationConfig | None = None,
) -> None:
"""Create a new OpenAI provider.
@@ -113,6 +115,8 @@ class MultiProvider(ModelProvider):
behavior and raises ``UserError``. ``"model_id"`` passes the full string through to
the OpenAI provider so OpenAI-compatible endpoints can receive namespaced model IDs
such as ``openrouter/openai/gpt-4o``.
+ openai_agent_registration: Optional agent registration configuration for the OpenAI
+ provider.
"""
self.provider_map = provider_map
self.openai_provider = OpenAIProvider(
@@ -124,6 +128,7 @@ class MultiProvider(ModelProvider):
project=openai_project,
use_responses=openai_use_responses,
use_responses_websocket=openai_use_responses_websocket,
+ agent_registration=openai_agent_registration,
)
self._openai_prefix_mode = self._validate_openai_prefix_mode(openai_prefix_mode)
self._unknown_prefix_mode = self._validate_unknown_prefix_mode(unknown_prefix_mode)
diff --git a/src/agents/models/openai_agent_registration.py b/src/agents/models/openai_agent_registration.py
new file mode 100644
index 00000000..12e62d8b
--- /dev/null
+++ b/src/agents/models/openai_agent_registration.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from typing import Any
+
+_ENV_HARNESS_ID = "OPENAI_AGENT_HARNESS_ID"
+OPENAI_HARNESS_ID_TRACE_METADATA_KEY = "agent_harness_id"
+
+
+@dataclass(frozen=True)
+class OpenAIAgentRegistrationConfig:
+ harness_id: str | None
+
+
+@dataclass(frozen=True)
+class ResolvedOpenAIAgentRegistrationConfig:
+ harness_id: str
+
+
+_default_agent_registration: OpenAIAgentRegistrationConfig | None = None
+
+
+def set_default_openai_agent_registration_config(
+ config: OpenAIAgentRegistrationConfig | None,
+) -> None:
+ global _default_agent_registration
+ _default_agent_registration = config
+
+
+def get_default_openai_agent_registration_config() -> OpenAIAgentRegistrationConfig | None:
+ return _default_agent_registration
+
+
+def resolve_openai_agent_registration_config(
+ config: OpenAIAgentRegistrationConfig | None,
+) -> ResolvedOpenAIAgentRegistrationConfig | None:
+ default = get_default_openai_agent_registration_config()
+ harness_id = _resolve_str(
+ explicit=config.harness_id if config else None,
+ default=default.harness_id if default else None,
+ env_name=_ENV_HARNESS_ID,
+ )
+ if harness_id is None:
+ return None
+ return ResolvedOpenAIAgentRegistrationConfig(harness_id=harness_id)
+
+
+def resolve_openai_harness_id_for_model_provider(model_provider: Any) -> str | None:
+ """Return the configured harness ID for OpenAI-backed model providers."""
+ harness_id = _harness_id_from_model_provider(model_provider)
+ if harness_id is not None:
+ return harness_id
+ resolved = resolve_openai_agent_registration_config(None)
+ return resolved.harness_id if resolved is not None else None
+
+
+def add_openai_harness_id_to_metadata(
+ metadata: dict[str, Any] | None,
+ *,
+ model_provider: Any,
+) -> dict[str, Any] | None:
+ harness_id = resolve_openai_harness_id_for_model_provider(model_provider)
+ if harness_id is None:
+ return metadata
+ if metadata is not None and OPENAI_HARNESS_ID_TRACE_METADATA_KEY in metadata:
+ return metadata
+
+ updated_metadata = dict(metadata or {})
+ updated_metadata[OPENAI_HARNESS_ID_TRACE_METADATA_KEY] = harness_id
+ return updated_metadata
+
+
+def _harness_id_from_model_provider(model_provider: Any) -> str | None:
+ registration = getattr(model_provider, "agent_registration", None)
+ harness_id = _harness_id_from_registration(registration)
+ if harness_id is not None:
+ return harness_id
+
+ registration = getattr(model_provider, "_agent_registration", None)
+ harness_id = _harness_id_from_registration(registration)
+ if harness_id is not None:
+ return harness_id
+
+ openai_provider = getattr(model_provider, "openai_provider", None)
+ if openai_provider is not None and openai_provider is not model_provider:
+ return _harness_id_from_model_provider(openai_provider)
+ return None
+
+
+def _harness_id_from_registration(registration: Any) -> str | None:
+ if registration is None:
+ return None
+ harness_id = getattr(registration, "harness_id", None)
+ return harness_id if isinstance(harness_id, str) and harness_id.strip() else None
+
+
+def _resolve_str(*, explicit: str | None, default: str | None, env_name: str) -> str | None:
+ for candidate in (explicit, default, os.getenv(env_name)):
+ if candidate is None:
+ continue
+ stripped = candidate.strip()
+ if stripped:
+ return stripped
+ return None
diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py
index 454bd7af..bf0713d7 100644
--- a/src/agents/models/openai_chatcompletions.py
+++ b/src/agents/models/openai_chatcompletions.py
@@ -63,6 +63,9 @@ class OpenAIChatCompletionsModel(Model):
def _non_null_or_omit(self, value: Any) -> Any:
return value if value is not None else omit
+ def _supports_default_prompt_cache_key(self) -> bool:
+ return ChatCmplHelpers.is_openai(self._get_client())
+
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
return get_openai_retry_advice(request)
@@ -130,7 +133,6 @@ class OpenAIChatCompletionsModel(Model):
stream=False,
prompt=prompt,
)
-
message: ChatCompletionMessage | None = None
first_choice: Choice | None = None
if response.choices and len(response.choices) > 0:
@@ -388,31 +390,46 @@ class OpenAIChatCompletionsModel(Model):
stream_param: Literal[True] | Omit = True if stream else omit
- ret = await self._get_client().chat.completions.create(
- model=self.model,
- messages=converted_messages,
- tools=tools_param,
- temperature=self._non_null_or_omit(model_settings.temperature),
- top_p=self._non_null_or_omit(model_settings.top_p),
- frequency_penalty=self._non_null_or_omit(model_settings.frequency_penalty),
- presence_penalty=self._non_null_or_omit(model_settings.presence_penalty),
- max_tokens=self._non_null_or_omit(model_settings.max_tokens),
- tool_choice=tool_choice,
- response_format=response_format,
- parallel_tool_calls=parallel_tool_calls,
- stream=cast(Any, stream_param),
- stream_options=self._non_null_or_omit(stream_options),
- store=self._non_null_or_omit(store),
- reasoning_effort=self._non_null_or_omit(reasoning_effort),
- verbosity=self._non_null_or_omit(model_settings.verbosity),
- top_logprobs=self._non_null_or_omit(model_settings.top_logprobs),
- prompt_cache_retention=self._non_null_or_omit(model_settings.prompt_cache_retention),
- extra_headers=self._merge_headers(model_settings),
- extra_query=model_settings.extra_query,
- extra_body=model_settings.extra_body,
- metadata=self._non_null_or_omit(model_settings.metadata),
- **(model_settings.extra_args or {}),
+ create_kwargs: dict[str, Any] = {
+ "model": self.model,
+ "messages": converted_messages,
+ "tools": tools_param,
+ "temperature": self._non_null_or_omit(model_settings.temperature),
+ "top_p": self._non_null_or_omit(model_settings.top_p),
+ "frequency_penalty": self._non_null_or_omit(model_settings.frequency_penalty),
+ "presence_penalty": self._non_null_or_omit(model_settings.presence_penalty),
+ "max_tokens": self._non_null_or_omit(model_settings.max_tokens),
+ "tool_choice": tool_choice,
+ "response_format": response_format,
+ "parallel_tool_calls": parallel_tool_calls,
+ "stream": cast(Any, stream_param),
+ "stream_options": self._non_null_or_omit(stream_options),
+ "store": self._non_null_or_omit(store),
+ "reasoning_effort": self._non_null_or_omit(reasoning_effort),
+ "verbosity": self._non_null_or_omit(model_settings.verbosity),
+ "top_logprobs": self._non_null_or_omit(model_settings.top_logprobs),
+ "prompt_cache_retention": self._non_null_or_omit(model_settings.prompt_cache_retention),
+ "extra_headers": self._merge_headers(model_settings),
+ "extra_query": model_settings.extra_query,
+ "extra_body": model_settings.extra_body,
+ "metadata": self._non_null_or_omit(model_settings.metadata),
+ }
+ duplicate_extra_arg_keys = sorted(
+ set(create_kwargs).intersection(model_settings.extra_args or {})
)
+ if duplicate_extra_arg_keys:
+ if len(duplicate_extra_arg_keys) == 1:
+ key = duplicate_extra_arg_keys[0]
+ raise TypeError(
+ f"chat.completions.create() got multiple values for keyword argument '{key}'"
+ )
+ keys = ", ".join(repr(key) for key in duplicate_extra_arg_keys)
+ raise TypeError(
+ f"chat.completions.create() got multiple values for keyword arguments {keys}"
+ )
+ create_kwargs.update(model_settings.extra_args or {})
+
+ ret = await self._get_client().chat.completions.create(**create_kwargs)
if isinstance(ret, ChatCompletion):
return ret
diff --git a/src/agents/models/openai_client_utils.py b/src/agents/models/openai_client_utils.py
new file mode 100644
index 00000000..7f81d1ef
--- /dev/null
+++ b/src/agents/models/openai_client_utils.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from urllib.parse import urlsplit
+
+from openai import AsyncOpenAI
+
+
+def is_official_openai_base_url(base_url: object, *, websocket: bool = False) -> bool:
+ parsed = urlsplit(str(base_url))
+ expected_scheme = "wss" if websocket else "https"
+ return parsed.scheme == expected_scheme and parsed.hostname == "api.openai.com"
+
+
+def is_official_openai_client(client: AsyncOpenAI) -> bool:
+ base_url = getattr(client, "base_url", None)
+ if base_url is None:
+ return False
+ return is_official_openai_base_url(base_url)
diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py
index 91265c0a..31e4375a 100644
--- a/src/agents/models/openai_provider.py
+++ b/src/agents/models/openai_provider.py
@@ -10,6 +10,11 @@ from openai import AsyncOpenAI, DefaultAsyncHttpxClient
from . import _openai_shared
from .default_models import get_default_model
from .interface import Model, ModelProvider
+from .openai_agent_registration import (
+ OpenAIAgentRegistrationConfig,
+ ResolvedOpenAIAgentRegistrationConfig,
+ resolve_openai_agent_registration_config,
+)
from .openai_chatcompletions import OpenAIChatCompletionsModel
from .openai_responses import OpenAIResponsesModel, OpenAIResponsesWSModel
@@ -43,6 +48,7 @@ class OpenAIProvider(ModelProvider):
project: str | None = None,
use_responses: bool | None = None,
use_responses_websocket: bool | None = None,
+ agent_registration: OpenAIAgentRegistrationConfig | None = None,
) -> None:
"""Create a new OpenAI provider.
@@ -60,6 +66,7 @@ class OpenAIProvider(ModelProvider):
use_responses: Whether to use the OpenAI responses API.
use_responses_websocket: Whether to use websocket transport for the OpenAI responses
API.
+ agent_registration: Optional agent registration configuration.
"""
if openai_client is not None:
assert api_key is None and base_url is None and websocket_base_url is None, (
@@ -94,6 +101,11 @@ class OpenAIProvider(ModelProvider):
self._ws_model_cache_by_loop: weakref.WeakKeyDictionary[
asyncio.AbstractEventLoop, _WSLoopModelCache
] = weakref.WeakKeyDictionary()
+ self._agent_registration = resolve_openai_agent_registration_config(agent_registration)
+
+ @property
+ def agent_registration(self) -> ResolvedOpenAIAgentRegistrationConfig | None:
+ return self._agent_registration
# We lazy load the client in case you never actually use OpenAIProvider(). Otherwise
# AsyncOpenAI() raises an error if you don't have an API key set.
diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py
index 683e15a6..d4037630 100644
--- a/src/agents/models/openai_responses.py
+++ b/src/agents/models/openai_responses.py
@@ -16,6 +16,7 @@ from openai import AsyncOpenAI, NotGiven, Omit, omit
from openai.types import ChatModel
from openai.types.responses import (
ApplyPatchToolParam,
+ CustomToolParam,
FileSearchToolParam,
FunctionToolParam,
Response,
@@ -47,6 +48,7 @@ from ..tool import (
ApplyPatchTool,
CodeInterpreterTool,
ComputerTool,
+ CustomTool,
FileSearchTool,
FunctionTool,
HostedMCPTool,
@@ -61,7 +63,7 @@ from ..tool import (
validate_responses_tool_search_configuration,
)
from ..tracing import SpanError, response_span
-from ..usage import Usage
+from ..usage import Usage, model_usage_to_span_usage
from ..util._json import _to_dump_compatible
from ..version import __version__
from ._openai_retry import get_openai_retry_advice
@@ -71,6 +73,7 @@ from ._retry_runtime import (
)
from .fake_id import FAKE_RESPONSES_ID
from .interface import Model, ModelTracing
+from .openai_client_utils import is_official_openai_base_url, is_official_openai_client
if TYPE_CHECKING:
from ..model_settings import ModelSettings
@@ -113,7 +116,7 @@ def _json_dumps_default(value: Any) -> Any:
def _is_openai_omitted_value(value: Any) -> bool:
- return isinstance(value, (Omit, NotGiven))
+ return isinstance(value, Omit | NotGiven)
def _require_responses_tool_param(value: object) -> ResponsesToolParam:
@@ -390,6 +393,9 @@ class OpenAIResponsesModel(Model):
def _non_null_or_omit(self, value: Any) -> Any:
return value if value is not None else omit
+ def _supports_default_prompt_cache_key(self) -> bool:
+ return is_official_openai_client(self._get_client())
+
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
return get_openai_retry_advice(request)
@@ -472,6 +478,8 @@ class OpenAIResponsesModel(Model):
if response.usage
else Usage()
)
+ if response.usage:
+ span_response.span_data.usage = model_usage_to_span_usage(usage)
if tracing.include_data():
span_response.span_data.response = response
@@ -569,6 +577,17 @@ class OpenAIResponsesModel(Model):
if final_response and tracing.include_data():
span_response.span_data.response = final_response
span_response.span_data.input = input
+ if final_response and final_response.usage:
+ span_response.span_data.usage = model_usage_to_span_usage(
+ Usage(
+ requests=1,
+ input_tokens=final_response.usage.input_tokens,
+ output_tokens=final_response.usage.output_tokens,
+ total_tokens=final_response.usage.total_tokens,
+ input_tokens_details=final_response.usage.input_tokens_details,
+ output_tokens_details=final_response.usage.output_tokens_details,
+ )
+ )
except Exception as e:
span_response.set_error(
@@ -905,6 +924,11 @@ class OpenAIResponsesWSModel(OpenAIResponsesModel):
)
self._ws_client_close_generation = 0
+ def _supports_default_prompt_cache_key(self) -> bool:
+ if self._client.websocket_base_url is not None:
+ return is_official_openai_base_url(self._client.websocket_base_url, websocket=True)
+ return super()._supports_default_prompt_cache_key()
+
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
stateful_request = bool(request.previous_response_id or request.conversation_id)
wrapped_replay_safety = _get_wrapped_websocket_replay_safety(request.error)
@@ -1223,7 +1247,7 @@ class OpenAIResponsesWSModel(OpenAIResponsesModel):
recv=None if timeout.read is None else float(timeout.read),
)
- if isinstance(timeout, (int, float)):
+ if isinstance(timeout, int | float):
timeout_seconds = float(timeout)
return _WebsocketRequestTimeouts(
lock=timeout_seconds,
@@ -1714,7 +1738,7 @@ class Converter:
def _has_unresolved_computer_tool(cls, tools: Sequence[Tool] | None) -> bool:
return any(
isinstance(tool, ComputerTool)
- and not isinstance(tool.computer, (Computer, AsyncComputer))
+ and not isinstance(tool.computer, Computer | AsyncComputer)
for tool in tools or ()
)
@@ -1901,7 +1925,7 @@ class Converter:
@classmethod
def _convert_preview_computer_tool(cls, tool: ComputerTool[Any]) -> ResponsesToolParam:
computer = tool.computer
- if not isinstance(computer, (Computer, AsyncComputer)):
+ if not isinstance(computer, Computer | AsyncComputer):
raise UserError(
"Computer tool is not initialized for serialization. Call "
"resolve_computer({ tool, run_context }) with a run context first "
@@ -1970,9 +1994,15 @@ class Converter:
else _require_responses_tool_param({"type": "computer"}),
None,
)
+ elif isinstance(tool, CustomTool):
+ custom_tool_param: CustomToolParam = tool.tool_config
+ return custom_tool_param, None
elif isinstance(tool, HostedMCPTool):
return tool.tool_config, None
elif isinstance(tool, ApplyPatchTool):
+ tool_config = getattr(tool, "tool_config", None)
+ if tool_config is not None:
+ return _require_responses_tool_param(tool_config), None
return ApplyPatchToolParam(type="apply_patch"), None
elif isinstance(tool, ShellTool):
return (
diff --git a/src/agents/models/reasoning_content_replay.py b/src/agents/models/reasoning_content_replay.py
index 03d8cf2b..0f46b3d8 100644
--- a/src/agents/models/reasoning_content_replay.py
+++ b/src/agents/models/reasoning_content_replay.py
@@ -1,8 +1,8 @@
from __future__ import annotations
-from collections.abc import Mapping
+from collections.abc import Callable, Mapping
from dataclasses import dataclass
-from typing import Any, Callable
+from typing import Any
@dataclass
diff --git a/src/agents/prompts.py b/src/agents/prompts.py
index 2a9834bb..02ea46c7 100644
--- a/src/agents/prompts.py
+++ b/src/agents/prompts.py
@@ -1,8 +1,9 @@
from __future__ import annotations
import inspect
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Callable, cast
+from typing import TYPE_CHECKING, Any, cast
from openai.types.responses.response_prompt_param import (
ResponsePromptParam,
diff --git a/src/agents/realtime/agent.py b/src/agents/realtime/agent.py
index c04053db..4d34258a 100644
--- a/src/agents/realtime/agent.py
+++ b/src/agents/realtime/agent.py
@@ -2,9 +2,9 @@ from __future__ import annotations
import dataclasses
import inspect
-from collections.abc import Awaitable
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
-from typing import Any, Callable, Generic, cast
+from typing import Any, Generic, cast
from agents.prompts import Prompt
diff --git a/src/agents/realtime/audio_formats.py b/src/agents/realtime/audio_formats.py
index fdfe1230..a47e16c5 100644
--- a/src/agents/realtime/audio_formats.py
+++ b/src/agents/realtime/audio_formats.py
@@ -32,7 +32,7 @@ def to_realtime_audio_format(
rate = input_audio_format.get("rate")
if fmt_type == "audio/pcm":
pcm_rate: Literal[24000] | None
- if isinstance(rate, (int, float)) and int(rate) == 24000:
+ if isinstance(rate, int | float) and int(rate) == 24000:
pcm_rate = 24000
elif rate is None:
pcm_rate = 24000
diff --git a/src/agents/realtime/config.py b/src/agents/realtime/config.py
index 43c6f9f0..4cc2ca55 100644
--- a/src/agents/realtime/config.py
+++ b/src/agents/realtime/config.py
@@ -1,12 +1,12 @@
from __future__ import annotations
from collections.abc import Mapping
-from typing import Any, Literal, Union
+from typing import Any, Literal, TypeAlias
from openai.types.realtime.realtime_audio_formats import (
RealtimeAudioFormats as OpenAIRealtimeAudioFormats,
)
-from typing_extensions import NotRequired, TypeAlias, TypedDict
+from typing_extensions import NotRequired, TypedDict
from agents.prompts import Prompt
@@ -16,7 +16,7 @@ from ..model_settings import ToolChoice
from ..run_config import ToolErrorFormatter
from ..tool import Tool
-RealtimeModelName: TypeAlias = Union[
+RealtimeModelName: TypeAlias = (
Literal[
"gpt-realtime",
"gpt-realtime-1.5",
@@ -30,18 +30,18 @@ RealtimeModelName: TypeAlias = Union[
"gpt-realtime-mini",
"gpt-realtime-mini-2025-10-06",
"gpt-realtime-mini-2025-12-15",
- ],
- str,
-]
+ ]
+ | str
+)
"""The name of a realtime model."""
-RealtimeAudioFormat: TypeAlias = Union[
- Literal["pcm16", "g711_ulaw", "g711_alaw"],
- str,
- Mapping[str, Any],
- OpenAIRealtimeAudioFormats,
-]
+RealtimeAudioFormat: TypeAlias = (
+ Literal["pcm16", "g711_ulaw", "g711_alaw"]
+ | str
+ | Mapping[str, Any]
+ | OpenAIRealtimeAudioFormats
+)
"""The audio format for realtime audio streams."""
@@ -264,5 +264,5 @@ class RealtimeUserInputMessage(TypedDict):
"""List of content items (text and image) in the message."""
-RealtimeUserInput: TypeAlias = Union[str, RealtimeUserInputMessage]
+RealtimeUserInput: TypeAlias = str | RealtimeUserInputMessage
"""User input that can be a string or structured message."""
diff --git a/src/agents/realtime/events.py b/src/agents/realtime/events.py
index 923e9b55..388dac37 100644
--- a/src/agents/realtime/events.py
+++ b/src/agents/realtime/events.py
@@ -1,9 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any, Literal, Union
-
-from typing_extensions import TypeAlias
+from typing import Any, Literal, TypeAlias
from ..guardrail import OutputGuardrailResult
from ..run_context import RunContextWrapper
@@ -255,21 +253,21 @@ class RealtimeInputAudioTimeoutTriggered:
type: Literal["input_audio_timeout_triggered"] = "input_audio_timeout_triggered"
-RealtimeSessionEvent: TypeAlias = Union[
- RealtimeAgentStartEvent,
- RealtimeAgentEndEvent,
- RealtimeHandoffEvent,
- RealtimeToolStart,
- RealtimeToolEnd,
- RealtimeToolApprovalRequired,
- RealtimeRawModelEvent,
- RealtimeAudioEnd,
- RealtimeAudio,
- RealtimeAudioInterrupted,
- RealtimeError,
- RealtimeHistoryUpdated,
- RealtimeHistoryAdded,
- RealtimeGuardrailTripped,
- RealtimeInputAudioTimeoutTriggered,
-]
+RealtimeSessionEvent: TypeAlias = (
+ RealtimeAgentStartEvent
+ | RealtimeAgentEndEvent
+ | RealtimeHandoffEvent
+ | RealtimeToolStart
+ | RealtimeToolEnd
+ | RealtimeToolApprovalRequired
+ | RealtimeRawModelEvent
+ | RealtimeAudioEnd
+ | RealtimeAudio
+ | RealtimeAudioInterrupted
+ | RealtimeError
+ | RealtimeHistoryUpdated
+ | RealtimeHistoryAdded
+ | RealtimeGuardrailTripped
+ | RealtimeInputAudioTimeoutTriggered
+)
"""An event emitted by the realtime session."""
diff --git a/src/agents/realtime/handoffs.py b/src/agents/realtime/handoffs.py
index 473ee00f..4f881244 100644
--- a/src/agents/realtime/handoffs.py
+++ b/src/agents/realtime/handoffs.py
@@ -1,7 +1,8 @@
from __future__ import annotations
import inspect
-from typing import TYPE_CHECKING, Any, Callable, cast, overload
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any, cast, overload
from pydantic import TypeAdapter
from typing_extensions import TypeVar
diff --git a/src/agents/realtime/items.py b/src/agents/realtime/items.py
index 58106fad..9965e7b2 100644
--- a/src/agents/realtime/items.py
+++ b/src/agents/realtime/items.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import Annotated, Literal, Union
+from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -149,7 +149,7 @@ class AssistantMessageItem(BaseModel):
RealtimeMessageItem = Annotated[
- Union[SystemMessageItem, UserMessageItem, AssistantMessageItem],
+ SystemMessageItem | UserMessageItem | AssistantMessageItem,
Field(discriminator="role"),
]
"""A message item that can be from system, user, or assistant."""
@@ -186,7 +186,7 @@ class RealtimeToolCallItem(BaseModel):
model_config = ConfigDict(extra="allow")
-RealtimeItem = Union[RealtimeMessageItem, RealtimeToolCallItem]
+RealtimeItem = RealtimeMessageItem | RealtimeToolCallItem
"""A realtime item that can be a message or tool call."""
diff --git a/src/agents/realtime/model.py b/src/agents/realtime/model.py
index 537acf9d..34511418 100644
--- a/src/agents/realtime/model.py
+++ b/src/agents/realtime/model.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import abc
-from typing import Callable
+from collections.abc import Callable
from typing_extensions import NotRequired, TypedDict
diff --git a/src/agents/realtime/model_events.py b/src/agents/realtime/model_events.py
index 7c839aa1..7715f98c 100644
--- a/src/agents/realtime/model_events.py
+++ b/src/agents/realtime/model_events.py
@@ -1,9 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any, Literal, Union
-
-from typing_extensions import TypeAlias
+from typing import Any, Literal, TypeAlias
from .items import RealtimeItem
@@ -179,21 +177,21 @@ class RealtimeModelRawServerEvent:
# TODO (rm) Add usage events
-RealtimeModelEvent: TypeAlias = Union[
- RealtimeModelErrorEvent,
- RealtimeModelToolCallEvent,
- RealtimeModelAudioEvent,
- RealtimeModelAudioInterruptedEvent,
- RealtimeModelAudioDoneEvent,
- RealtimeModelInputAudioTimeoutTriggeredEvent,
- RealtimeModelInputAudioTranscriptionCompletedEvent,
- RealtimeModelTranscriptDeltaEvent,
- RealtimeModelItemUpdatedEvent,
- RealtimeModelItemDeletedEvent,
- RealtimeModelConnectionStatusEvent,
- RealtimeModelTurnStartedEvent,
- RealtimeModelTurnEndedEvent,
- RealtimeModelOtherEvent,
- RealtimeModelExceptionEvent,
- RealtimeModelRawServerEvent,
-]
+RealtimeModelEvent: TypeAlias = (
+ RealtimeModelErrorEvent
+ | RealtimeModelToolCallEvent
+ | RealtimeModelAudioEvent
+ | RealtimeModelAudioInterruptedEvent
+ | RealtimeModelAudioDoneEvent
+ | RealtimeModelInputAudioTimeoutTriggeredEvent
+ | RealtimeModelInputAudioTranscriptionCompletedEvent
+ | RealtimeModelTranscriptDeltaEvent
+ | RealtimeModelItemUpdatedEvent
+ | RealtimeModelItemDeletedEvent
+ | RealtimeModelConnectionStatusEvent
+ | RealtimeModelTurnStartedEvent
+ | RealtimeModelTurnEndedEvent
+ | RealtimeModelOtherEvent
+ | RealtimeModelExceptionEvent
+ | RealtimeModelRawServerEvent
+)
diff --git a/src/agents/realtime/model_inputs.py b/src/agents/realtime/model_inputs.py
index 411177b7..c167ce34 100644
--- a/src/agents/realtime/model_inputs.py
+++ b/src/agents/realtime/model_inputs.py
@@ -1,9 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any, Literal, Union
+from typing import Any, Literal, TypeAlias
-from typing_extensions import NotRequired, TypeAlias, TypedDict
+from typing_extensions import NotRequired, TypedDict
from .config import RealtimeSessionModelSettings
from .model_events import RealtimeModelToolCallEvent
@@ -46,7 +46,7 @@ class RealtimeModelUserInputMessage(TypedDict):
content: list[RealtimeModelInputTextContent | RealtimeModelInputImageContent]
-RealtimeModelUserInput: TypeAlias = Union[str, RealtimeModelUserInputMessage]
+RealtimeModelUserInput: TypeAlias = str | RealtimeModelUserInputMessage
"""A user input to be sent to the model."""
@@ -107,11 +107,11 @@ class RealtimeModelSendSessionUpdate:
"""The updated session settings to send."""
-RealtimeModelSendEvent: TypeAlias = Union[
- RealtimeModelSendRawMessage,
- RealtimeModelSendUserInput,
- RealtimeModelSendAudio,
- RealtimeModelSendToolOutput,
- RealtimeModelSendInterrupt,
- RealtimeModelSendSessionUpdate,
-]
+RealtimeModelSendEvent: TypeAlias = (
+ RealtimeModelSendRawMessage
+ | RealtimeModelSendUserInput
+ | RealtimeModelSendAudio
+ | RealtimeModelSendToolOutput
+ | RealtimeModelSendInterrupt
+ | RealtimeModelSendSessionUpdate
+)
diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py
index 29745d38..9ce1daf5 100644
--- a/src/agents/realtime/openai_realtime.py
+++ b/src/agents/realtime/openai_realtime.py
@@ -6,10 +6,10 @@ import inspect
import json
import math
import os
-from collections.abc import Mapping
+from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
-from typing import Annotated, Any, Callable, Literal, Union, cast
+from typing import Annotated, Any, Literal, TypeAlias, cast
import pydantic
import websockets
@@ -81,7 +81,7 @@ from openai.types.realtime.session_update_event import (
)
from openai.types.responses.response_prompt import ResponsePrompt
from pydantic import Field, TypeAdapter
-from typing_extensions import NotRequired, TypeAlias, TypedDict, assert_never
+from typing_extensions import NotRequired, TypedDict, assert_never
from websockets.asyncio.client import ClientConnection
from agents.handoffs import Handoff
@@ -142,14 +142,7 @@ from .model_inputs import (
RealtimeModelSendUserInput,
)
-FormatInput: TypeAlias = Union[
- str,
- AudioPCM,
- AudioPCMU,
- AudioPCMA,
- Mapping[str, Any],
- None,
-]
+FormatInput: TypeAlias = str | AudioPCM | AudioPCMU | AudioPCMA | Mapping[str, Any] | None
# Avoid direct imports of non-exported names by referencing via module
@@ -186,7 +179,7 @@ async def get_api_key(key: str | Callable[[], MaybeAwaitable[str]] | None) -> st
AllRealtimeServerEvents = Annotated[
- Union[OpenAIRealtimeServerEvent,],
+ OpenAIRealtimeServerEvent,
Field(discriminator="type"),
]
@@ -397,7 +390,7 @@ async def _collect_enabled_handoffs(
return res
results = await asyncio.gather(*(_check_handoff_enabled(h) for h in handoffs))
- return [h for h, ok in zip(handoffs, results) if ok]
+ return [h for h, ok in zip(handoffs, results, strict=False) if ok]
async def _build_model_settings_from_agent(
@@ -1578,11 +1571,9 @@ class _ConversionHelper:
) -> RealtimeMessageItem:
if not isinstance(
item,
- (
- RealtimeConversationItemUserMessage,
- RealtimeConversationItemAssistantMessage,
- RealtimeConversationItemSystemMessage,
- ),
+ RealtimeConversationItemUserMessage
+ | RealtimeConversationItemAssistantMessage
+ | RealtimeConversationItemSystemMessage,
):
raise ValueError("Unsupported conversation item type for message conversion.")
content: list[dict[str, Any]] = []
diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py
index da13a63c..89f63b02 100644
--- a/src/agents/realtime/session.py
+++ b/src/agents/realtime/session.py
@@ -347,7 +347,7 @@ class RealtimeSession(RealtimeModelListener):
# Only attempt to preserve for audio-like content
if entry.type in ("audio", "input_audio"):
# Use tuple form when checking against multiple classes.
- assert isinstance(entry, (InputAudio, AssistantAudio))
+ assert isinstance(entry, InputAudio | AssistantAudio)
# Determine if transcript is missing/empty on the incoming entry
entry_transcript = entry.transcript
if not entry_transcript:
@@ -1108,5 +1108,5 @@ class RealtimeSession(RealtimeModelListener):
return res
results = await asyncio.gather(*(_check_handoff_enabled(h) for h in handoffs))
- enabled = [h for h, ok in zip(handoffs, results) if ok]
+ enabled = [h for h, ok in zip(handoffs, results, strict=False) if ok]
return enabled
diff --git a/src/agents/result.py b/src/agents/result.py
index 774c90dc..807e3c0a 100644
--- a/src/agents/result.py
+++ b/src/agents/result.py
@@ -46,7 +46,9 @@ from .util._pretty_print import (
)
if TYPE_CHECKING:
- pass
+ from collections.abc import Awaitable, Callable
+
+ from .sandbox.session.base_sandbox_session import BaseSandboxSession
T = TypeVar("T")
@@ -78,6 +80,7 @@ def _populate_state_from_result(
auto_previous_response_id: bool = False,
) -> RunState[Any]:
"""Populate a RunState with common fields from a RunResult."""
+ state._current_agent = result.last_agent
model_input_items = getattr(result, "_model_input_items", None)
if isinstance(model_input_items, list):
state._generated_items = list(model_input_items)
@@ -96,6 +99,11 @@ def _populate_state_from_result(
state._conversation_id = conversation_id
state._previous_response_id = previous_response_id
state._auto_previous_response_id = auto_previous_response_id
+ source_state = getattr(result, "_state", None)
+ if isinstance(source_state, RunState):
+ state._generated_prompt_cache_key = source_state._generated_prompt_cache_key
+ else:
+ state._generated_prompt_cache_key = getattr(result, "_generated_prompt_cache_key", None)
state._reasoning_item_id_policy = getattr(result, "_reasoning_item_id_policy", None)
interruptions = list(getattr(result, "interruptions", []))
@@ -106,6 +114,11 @@ def _populate_state_from_result(
if trace_state is None:
trace_state = TraceState.from_trace(getattr(result, "trace", None))
state._trace_state = copy.deepcopy(trace_state) if trace_state else None
+ sandbox_resume_state = getattr(result, "_sandbox_resume_state", None)
+ if isinstance(sandbox_resume_state, dict):
+ state._sandbox = copy.deepcopy(sandbox_resume_state)
+ else:
+ state._sandbox = None
return state
@@ -144,6 +157,20 @@ def _input_items_for_result(
return run_items_to_input_items(model_input_items, reasoning_item_id_policy)
+def _starting_agent_for_state(result: RunResultBase) -> Agent[Any]:
+ """Return the root agent graph that should seed RunState identity resolution."""
+ state = getattr(result, "_state", None)
+ starting_agent = getattr(state, "_starting_agent", None)
+ if isinstance(starting_agent, Agent):
+ return starting_agent
+
+ stored_starting_agent = getattr(result, "_starting_agent_for_state", None)
+ if isinstance(stored_starting_agent, Agent):
+ return stored_starting_agent
+
+ return result.last_agent
+
+
@dataclass
class RunResultBase(abc.ABC):
input: str | list[TResponseInputItem]
@@ -185,6 +212,14 @@ class RunResultBase(abc.ABC):
This is only set when the runner preserved extra session history items that should not be
replayed into the next local run, such as nested handoff history or filtered handoff input.
"""
+ _sandbox_resume_state: dict[str, object] | None = field(default=None, init=False, repr=False)
+ """Serialized sandbox session state captured during the run."""
+ _sandbox_session: BaseSandboxSession | None = field(default=None, init=False, repr=False)
+ """Live sandbox session attached to this run result when sandbox execution is enabled."""
+ _starting_agent_for_state: Agent[Any] | None = field(default=None, init=False, repr=False)
+ """Root agent graph used when converting the result back into RunState."""
+ _generated_prompt_cache_key: str | None = field(default=None, init=False, repr=False)
+ """SDK-generated prompt cache key captured during the run."""
@classmethod
def __get_pydantic_core_schema__(
@@ -385,7 +420,7 @@ class RunResult(RunResultBase):
original_input=original_input_for_state
if original_input_for_state is not None
else self.input,
- starting_agent=self.last_agent,
+ starting_agent=_starting_agent_for_state(self),
max_turns=self.max_turns,
)
@@ -470,7 +505,7 @@ class RunResultStreaming(RunResultBase):
_stream_input_persisted: bool = False
"""Whether the input has been persisted to the session. Prevents double-saving."""
- _original_input_for_persistence: list[TResponseInputItem] = field(default_factory=list)
+ _original_input_for_persistence: list[TResponseInputItem] | None = None
"""Original turn input before session history was merged, used for
persistence (matches JS sessionInputOriginalSnapshot)."""
@@ -493,6 +528,13 @@ class RunResultStreaming(RunResultBase):
)
"""How reasoning IDs should be represented when converting to input history."""
_run_impl_task: InitVar[asyncio.Task[Any] | None] = None
+ _sandbox_cleanup: Callable[[], Awaitable[None]] | None = field(
+ default=None,
+ init=False,
+ repr=False,
+ )
+ _sandbox_cleanup_task: asyncio.Task[None] | None = field(default=None, init=False, repr=False)
+ _sandbox_cleanup_callback_registered: bool = field(default=False, init=False, repr=False)
def __post_init__(self, _run_impl_task: asyncio.Task[Any] | None) -> None:
self._current_agent_ref = weakref.ref(self.current_agent)
@@ -525,6 +567,57 @@ class RunResultStreaming(RunResultBase):
# Preserve dataclass field so repr/asdict continue to succeed.
self.__dict__["current_agent"] = None
+ async def _run_sandbox_cleanup(self) -> None:
+ sandbox_cleanup = self._sandbox_cleanup
+ if sandbox_cleanup is None:
+ return
+
+ task = self._sandbox_cleanup_task
+ if task is None:
+
+ async def _cleanup_once() -> None:
+ try:
+ await sandbox_cleanup()
+ except Exception as error:
+ logger.warning(
+ "Failed to clean up sandbox resources after streamed run: %s", error
+ )
+
+ task = asyncio.create_task(_cleanup_once())
+ self._sandbox_cleanup_task = task
+
+ await task
+
+ def ensure_sandbox_cleanup_on_completion(self) -> None:
+ if (
+ self._sandbox_cleanup is None
+ or self.run_loop_task is None
+ or self._sandbox_cleanup_callback_registered
+ ):
+ return
+
+ original_task = self.run_loop_task
+ self._sandbox_cleanup_callback_registered = True
+ original_task.add_done_callback(
+ lambda _task: asyncio.create_task(self._run_sandbox_cleanup())
+ )
+
+ async def _await_run_and_cleanup() -> Any:
+ try:
+ result = await original_task
+ except asyncio.CancelledError:
+ if not original_task.done():
+ original_task.cancel()
+ raise
+ except Exception:
+ await self._run_sandbox_cleanup()
+ raise
+
+ await self._run_sandbox_cleanup()
+ return result
+
+ self.run_loop_task = asyncio.create_task(_await_run_and_cleanup())
+
def cancel(self, mode: Literal["immediate", "after_turn"] = "immediate") -> None:
"""Cancel the streaming run.
@@ -622,24 +715,28 @@ class RunResultStreaming(RunResultBase):
yield item
self._event_queue.task_done()
finally:
- if cancelled:
- # Cancellation should return promptly, so avoid waiting on long-running tasks.
- # Tasks have already been cancelled above.
- self._cleanup_tasks()
- else:
- # Ensure main execution completes before cleanup to avoid race conditions
- # with session operations
- await self._await_task_safely(self.run_loop_task)
- # Safely terminate all background tasks after main execution has finished
- self._cleanup_tasks()
+ try:
+ if cancelled:
+ # Cancellation should return promptly, so avoid waiting on long-running tasks.
+ # Tasks have already been cancelled above.
+ self._cleanup_tasks()
+ else:
+ # Ensure main execution completes before cleanup to avoid race conditions
+ # with session operations.
+ await self._await_task_safely(self.run_loop_task)
+ # Safely terminate all background tasks after main execution has finished.
+ self._cleanup_tasks()
- # Allow any pending callbacks (e.g., cancellation handlers) to enqueue their
- # completion sentinels before we clear the queues for observability.
- await asyncio.sleep(0)
+ if not cancelled:
+ await self._run_sandbox_cleanup()
+ finally:
+ # Allow any pending callbacks (e.g., cancellation handlers) to enqueue their
+ # completion sentinels before we clear the queues for observability.
+ await asyncio.sleep(0)
- # Drain queues so callers observing internal state see them empty after completion.
- self._drain_event_queue()
- self._drain_input_guardrail_queue()
+ # Drain queues so callers observing internal state see them empty after completion.
+ self._drain_event_queue()
+ self._drain_input_guardrail_queue()
if self._stored_exception:
raise self._stored_exception
@@ -781,7 +878,7 @@ class RunResultStreaming(RunResultBase):
state = RunState(
context=self.context_wrapper,
original_input=self._original_input if self._original_input is not None else self.input,
- starting_agent=self.last_agent,
+ starting_agent=_starting_agent_for_state(self),
max_turns=self.max_turns,
)
diff --git a/src/agents/retry.py b/src/agents/retry.py
index b567bfd8..f240a2d9 100644
--- a/src/agents/retry.py
+++ b/src/agents/retry.py
@@ -4,11 +4,10 @@ import dataclasses
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from inspect import isawaitable
-from typing import Any
+from typing import Any, TypeAlias
from pydantic import Field
from pydantic.dataclasses import dataclass as pydantic_dataclass
-from typing_extensions import TypeAlias
from .util._types import MaybeAwaitable
diff --git a/src/agents/run.py b/src/agents/run.py
index 047d454d..465a3ec6 100644
--- a/src/agents/run.py
+++ b/src/agents/run.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import contextlib
import warnings
-from typing import Union, cast
+from typing import cast
from typing_extensions import Unpack
@@ -43,9 +43,11 @@ from .run_config import (
)
from .run_context import RunContextWrapper, TContext
from .run_error_handlers import RunErrorHandlers
+from .run_internal.agent_bindings import bind_public_agent
from .run_internal.agent_runner_helpers import (
append_model_response_if_new,
apply_resumed_conversation_settings,
+ attach_usage_to_span,
build_interruption_result,
build_resumed_stream_debug_extra,
ensure_context_wrapper,
@@ -56,7 +58,9 @@ from .run_internal.agent_runner_helpers import (
resolve_trace_settings,
save_turn_items_if_needed,
should_cancel_parallel_model_task_on_input_guardrail_trip,
+ snapshot_usage,
update_run_state_for_interruption,
+ usage_delta,
validate_session_conversation_settings,
)
from .run_internal.approvals import approvals_from_step
@@ -72,6 +76,8 @@ from .run_internal.items import (
normalize_resumed_input,
)
from .run_internal.oai_conversation import OpenAIServerConversationTracker
+from .run_internal.prompt_cache_key import PromptCacheKeyResolver
+from .run_internal.run_grouping import resolve_run_grouping_id
from .run_internal.run_loop import (
get_all_tools,
get_handoffs,
@@ -106,11 +112,13 @@ from .run_internal.tool_use_tracker import (
serialize_tool_use_tracker,
)
from .run_state import RunState
+from .sandbox.memory.rollouts import terminal_metadata_for_exception
+from .sandbox.runtime import SandboxRuntime
from .tool import dispose_resolved_computers
from .tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult
-from .tracing import Span, SpanError, agent_span, get_current_trace
+from .tracing import Span, SpanError, agent_span, get_current_trace, task_span, turn_span
from .tracing.context import TraceCtxManager, create_trace_for_run
-from .tracing.span_data import AgentSpanData
+from .tracing.span_data import AgentSpanData, TaskSpanData
from .util import _error_tracing
DEFAULT_AGENT_RUNNER: AgentRunner = None # type: ignore
@@ -153,6 +161,34 @@ def get_default_agent_runner() -> AgentRunner:
return DEFAULT_AGENT_RUNNER
+def _sandbox_memory_rollout_id(
+ *,
+ run_config: RunConfig,
+ conversation_id: str | None,
+ session: Session | None,
+) -> str | None:
+ if run_config.sandbox is None:
+ return None
+ return resolve_run_grouping_id(
+ conversation_id=conversation_id,
+ session=session,
+ group_id=run_config.group_id,
+ )
+
+
+def _sandbox_memory_input(
+ *,
+ memory_input_items_for_persistence: list[TResponseInputItem] | None,
+ original_user_input: str | list[TResponseInputItem] | None,
+ original_input: str | list[TResponseInputItem],
+) -> str | list[TResponseInputItem]:
+ if memory_input_items_for_persistence is not None:
+ return list(memory_input_items_for_persistence)
+ if original_user_input is not None:
+ return copy_input_items(original_user_input)
+ return copy_input_items(original_input)
+
+
class Runner:
@classmethod
async def run(
@@ -454,7 +490,7 @@ class AgentRunner:
max_turns = run_state._max_turns
else:
- raw_input = cast(Union[str, list[TResponseInputItem]], input)
+ raw_input = cast(str | list[TResponseInputItem], input)
original_user_input = raw_input
validate_session_conversation_settings(
@@ -516,6 +552,11 @@ class AgentRunner:
else:
server_conversation_tracker = None
session_persistence_enabled = session is not None and server_conversation_tracker is None
+ memory_input_items_for_persistence = (
+ list(session_input_items_for_persistence)
+ if session_persistence_enabled and session_input_items_for_persistence is not None
+ else None
+ )
if server_conversation_tracker is not None and is_resumed_state and run_state is not None:
session_input_items: list[TResponseInputItem] | None = None
@@ -583,60 +624,181 @@ class AgentRunner:
run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy
run_state.set_trace(get_current_trace())
- def _with_reasoning_item_id_policy(result: RunResult) -> RunResult:
- result._reasoning_item_id_policy = resolved_reasoning_item_id_policy
- if run_state is not None:
- run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy
- return result
+ current_task_span: Span[TaskSpanData] = task_span(name=trace_workflow_name)
+ current_task_span.start(mark_as_current=True)
+ task_usage_start = snapshot_usage(context_wrapper.usage)
- pending_server_items: list[RunItem] | None = None
- input_guardrail_results: list[InputGuardrailResult] = (
- list(run_state._input_guardrail_results) if run_state is not None else []
- )
- tool_input_guardrail_results: list[ToolInputGuardrailResult] = (
- list(getattr(run_state, "_tool_input_guardrail_results", []))
- if run_state is not None
- else []
- )
- tool_output_guardrail_results: list[ToolOutputGuardrailResult] = (
- list(getattr(run_state, "_tool_output_guardrail_results", []))
- if run_state is not None
- else []
- )
-
- current_span: Span[AgentSpanData] | None = None
- if is_resumed_state and run_state is not None and run_state._current_agent is not None:
- current_agent = run_state._current_agent
- else:
- current_agent = starting_agent
- should_run_agent_start_hooks = True
- store_setting = current_agent.model_settings.resolve(run_config.model_settings).store
-
- if (
- not is_resumed_state
- and session_persistence_enabled
- and original_user_input is not None
- and session_input_items_for_persistence is None
- ):
- session_input_items_for_persistence = ItemHelpers.input_to_new_input_list(
- original_user_input
+ try:
+ sandbox_runtime = SandboxRuntime(
+ starting_agent=starting_agent,
+ run_config=run_config,
+ rollout_id=_sandbox_memory_rollout_id(
+ run_config=run_config,
+ conversation_id=conversation_id,
+ session=session,
+ ),
+ run_state=run_state,
+ )
+ prompt_cache_key_resolver = PromptCacheKeyResolver.from_run_state(
+ run_state=run_state,
)
- if session_persistence_enabled and session_input_items_for_persistence:
- # Capture the exact input saved so it can be rewound on conversation lock retries.
- last_saved_input_snapshot_for_rewind = list(session_input_items_for_persistence)
- await save_result_to_session(
- session,
- session_input_items_for_persistence,
- [],
- run_state,
- store=store_setting,
+ completed_result: RunResult | None = None
+ run_exception: BaseException | None = None
+
+ def _with_reasoning_item_id_policy(result: RunResult) -> RunResult:
+ result._reasoning_item_id_policy = resolved_reasoning_item_id_policy
+ if run_state is not None:
+ run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy
+ return result
+
+ def _tool_use_tracker_snapshot() -> dict[str, list[str]]:
+ identity_root_agent = starting_agent
+ if run_state is not None and run_state._starting_agent is not None:
+ identity_root_agent = run_state._starting_agent
+ return serialize_tool_use_tracker(
+ tool_use_tracker,
+ starting_agent=identity_root_agent,
+ )
+
+ def _finalize_result(result: RunResult) -> RunResult:
+ nonlocal completed_result
+ result._starting_agent_for_state = (
+ run_state._starting_agent
+ if run_state is not None and run_state._starting_agent is not None
+ else starting_agent
+ )
+ finalized_result = finalize_conversation_tracking(
+ _with_reasoning_item_id_policy(result),
+ server_conversation_tracker=server_conversation_tracker,
+ run_state=run_state,
+ )
+ sandbox_runtime.apply_result_metadata(finalized_result)
+ if run_state is not None:
+ finalized_result._generated_prompt_cache_key = (
+ run_state._generated_prompt_cache_key
+ )
+ completed_result = finalized_result
+ return finalized_result
+
+ pending_server_items: list[RunItem] | None = None
+ input_guardrail_results: list[InputGuardrailResult] = (
+ list(run_state._input_guardrail_results) if run_state is not None else []
)
- session_input_items_for_persistence = []
+ tool_input_guardrail_results: list[ToolInputGuardrailResult] = (
+ list(getattr(run_state, "_tool_input_guardrail_results", []))
+ if run_state is not None
+ else []
+ )
+ tool_output_guardrail_results: list[ToolOutputGuardrailResult] = (
+ list(getattr(run_state, "_tool_output_guardrail_results", []))
+ if run_state is not None
+ else []
+ )
+
+ current_span: Span[AgentSpanData] | None = None
+ if (
+ is_resumed_state
+ and run_state is not None
+ and run_state._current_agent is not None
+ ):
+ current_agent = run_state._current_agent
+ else:
+ current_agent = starting_agent
+ sandbox_runtime.assert_agent_supported(current_agent)
+ should_run_agent_start_hooks = True
+ store_setting = current_agent.model_settings.resolve(
+ run_config.model_settings
+ ).store
+
+ if (
+ not is_resumed_state
+ and session_persistence_enabled
+ and original_user_input is not None
+ and session_input_items_for_persistence is None
+ ):
+ sandbox_runtime.assert_agent_supported(current_agent)
+ session_input_items_for_persistence = ItemHelpers.input_to_new_input_list(
+ original_user_input
+ )
+
+ if (
+ session_persistence_enabled
+ and session_input_items_for_persistence
+ and not sandbox_runtime.enabled
+ ):
+ # Capture the exact input saved so it can be rewound on conversation
+ # lock retries.
+ last_saved_input_snapshot_for_rewind = list(session_input_items_for_persistence)
+ await save_result_to_session(
+ session,
+ session_input_items_for_persistence,
+ [],
+ run_state,
+ store=store_setting,
+ )
+ session_input_items_for_persistence = []
+ except BaseException:
+ attach_usage_to_span(
+ current_task_span,
+ usage_delta(task_usage_start, context_wrapper.usage),
+ )
+ current_task_span.finish(reset_current=True)
+ raise
try:
while True:
resuming_turn = is_resumed_state
+ all_input_guardrails = (
+ starting_agent.input_guardrails + (run_config.input_guardrails or [])
+ if current_turn == 0 and not resuming_turn
+ else []
+ )
+ sequential_guardrails = [
+ g for g in all_input_guardrails if not g.run_in_parallel
+ ]
+ parallel_guardrails = [g for g in all_input_guardrails if g.run_in_parallel]
+ sequential_results: list[InputGuardrailResult] = []
+ if sandbox_runtime.enabled and sequential_guardrails:
+ # Blocking first-turn guardrails must run before sandbox prep so a tripwire
+ # can prevent session creation, startup, or live-session mutation.
+ try:
+ sequential_results = await run_input_guardrails(
+ starting_agent,
+ sequential_guardrails,
+ copy_input_items(original_input),
+ context_wrapper,
+ )
+ except InputGuardrailTripwireTriggered:
+ session_input_items_for_persistence = (
+ await persist_session_items_for_guardrail_trip(
+ session,
+ server_conversation_tracker,
+ session_input_items_for_persistence,
+ original_user_input,
+ run_state,
+ store=store_setting,
+ )
+ )
+ raise
+ sequential_guardrails = []
+
+ current_bindings = bind_public_agent(current_agent)
+ execution_agent = current_bindings.execution_agent
+ prepared_sandbox = await sandbox_runtime.prepare_agent(
+ current_agent=current_agent,
+ current_input=original_input,
+ context_wrapper=context_wrapper,
+ is_resumed_state=resuming_turn,
+ )
+ current_bindings = prepared_sandbox.bindings
+ execution_agent = current_bindings.execution_agent
+ original_input = copy_input_items(prepared_sandbox.input)
+ if starting_input is not None and not isinstance(starting_input, RunState):
+ starting_input = copy_input_items(prepared_sandbox.input)
+ if run_state is not None:
+ run_state._original_input = copy_input_items(original_input)
+
normalized_starting_input: str | list[TResponseInputItem] = (
starting_input
if starting_input is not None and not isinstance(starting_input, RunState)
@@ -645,6 +807,18 @@ class AgentRunner:
store_setting = current_agent.model_settings.resolve(
run_config.model_settings
).store
+ if session_persistence_enabled and session_input_items_for_persistence:
+ last_saved_input_snapshot_for_rewind = list(
+ session_input_items_for_persistence
+ )
+ await save_result_to_session(
+ session,
+ list(last_saved_input_snapshot_for_rewind),
+ [],
+ run_state,
+ store=store_setting,
+ )
+ session_input_items_for_persistence = []
if run_state is not None and run_state._current_step is not None:
if isinstance(run_state._current_step, NextStepInterruption):
logger.debug("Continuing from interruption")
@@ -655,7 +829,7 @@ class AgentRunner:
raise UserError("No model response found in previous state")
turn_result = await resolve_interrupted_turn(
- agent=current_agent,
+ bindings=current_bindings,
original_input=original_input,
original_pre_step_items=generated_items,
new_response=run_state._model_responses[-1],
@@ -750,11 +924,7 @@ class AgentRunner:
run_state=run_state,
original_input=original_input,
)
- return finalize_conversation_tracking(
- _with_reasoning_item_id_policy(result),
- server_conversation_tracker=server_conversation_tracker,
- run_state=run_state,
- )
+ return _finalize_result(result)
if isinstance(turn_result.next_step, NextStepRunAgain):
continue
@@ -791,9 +961,7 @@ class AgentRunner:
tool_output_guardrail_results=tool_output_guardrail_results,
context_wrapper=context_wrapper,
interruptions=approvals_from_state,
- _tool_use_tracker_snapshot=serialize_tool_use_tracker(
- tool_use_tracker
- ),
+ _tool_use_tracker_snapshot=_tool_use_tracker_snapshot(),
max_turns=max_turns,
)
result._current_turn = current_turn
@@ -820,11 +988,7 @@ class AgentRunner:
store=store_setting,
)
result._original_input = copy_input_items(original_input)
- return finalize_conversation_tracking(
- _with_reasoning_item_id_policy(result),
- server_conversation_tracker=server_conversation_tracker,
- run_state=run_state,
- )
+ return _finalize_result(result)
elif isinstance(turn_result.next_step, NextStepHandoff):
current_agent = cast(
Agent[TContext], turn_result.next_step.new_agent
@@ -844,16 +1008,17 @@ class AgentRunner:
if run_state is not None:
if run_state._current_step is None:
run_state._current_step = NextStepRunAgain() # type: ignore[assignment]
- all_tools = await get_all_tools(current_agent, context_wrapper)
+ all_tools = await get_all_tools(execution_agent, context_wrapper)
await initialize_computer_tools(
tools=all_tools, context_wrapper=context_wrapper
)
if current_span is None:
handoff_names = [
- h.agent_name for h in await get_handoffs(current_agent, context_wrapper)
+ h.agent_name
+ for h in await get_handoffs(execution_agent, context_wrapper)
]
- if output_schema := get_output_schema(current_agent):
+ if output_schema := get_output_schema(execution_agent):
output_type_name = output_schema.name()
else:
output_type_name = "str"
@@ -932,7 +1097,7 @@ class AgentRunner:
tool_output_guardrail_results=tool_output_guardrail_results,
context_wrapper=context_wrapper,
interruptions=approvals_from_state,
- _tool_use_tracker_snapshot=serialize_tool_use_tracker(tool_use_tracker),
+ _tool_use_tracker_snapshot=_tool_use_tracker_snapshot(),
max_turns=max_turns,
)
result._current_turn = max_turns
@@ -957,11 +1122,7 @@ class AgentRunner:
store=store_setting,
)
result._original_input = copy_input_items(original_input)
- return finalize_conversation_tracking(
- _with_reasoning_item_id_policy(result),
- server_conversation_tracker=server_conversation_tracker,
- run_state=run_state,
- )
+ return _finalize_result(result)
if run_state is not None and not resuming_turn:
run_state._current_turn_persisted_item_count = 0
@@ -982,41 +1143,94 @@ class AgentRunner:
else generated_items
)
- if current_turn <= 1:
- all_input_guardrails = starting_agent.input_guardrails + (
- run_config.input_guardrails or []
- )
- sequential_guardrails = [
- g for g in all_input_guardrails if not g.run_in_parallel
- ]
- parallel_guardrails = [g for g in all_input_guardrails if g.run_in_parallel]
-
- try:
- sequential_results = []
- if sequential_guardrails:
- sequential_results = await run_input_guardrails(
- starting_agent,
- sequential_guardrails,
- copy_input_items(prepared_input),
- context_wrapper,
+ turn_usage_start = snapshot_usage(context_wrapper.usage)
+ current_turn_span = turn_span(
+ turn=current_turn,
+ agent_name=current_agent.name,
+ )
+ current_turn_span.start(mark_as_current=True)
+ try:
+ if current_turn <= 1:
+ try:
+ if sequential_guardrails:
+ sequential_results = await run_input_guardrails(
+ starting_agent,
+ sequential_guardrails,
+ copy_input_items(original_input),
+ context_wrapper,
+ )
+ except InputGuardrailTripwireTriggered:
+ session_input_items_for_persistence = (
+ await persist_session_items_for_guardrail_trip(
+ session,
+ server_conversation_tracker,
+ session_input_items_for_persistence,
+ original_user_input,
+ run_state,
+ store=store_setting,
+ )
)
- except InputGuardrailTripwireTriggered:
- session_input_items_for_persistence = (
- await persist_session_items_for_guardrail_trip(
- session,
- server_conversation_tracker,
- session_input_items_for_persistence,
- original_user_input,
- run_state,
- store=store_setting,
+ raise
+
+ parallel_results: list[InputGuardrailResult] = []
+ model_task = asyncio.create_task(
+ run_single_turn(
+ bindings=current_bindings,
+ all_tools=all_tools,
+ original_input=original_input,
+ generated_items=items_for_model,
+ hooks=hooks,
+ context_wrapper=context_wrapper,
+ run_config=run_config,
+ should_run_agent_start_hooks=should_run_agent_start_hooks,
+ tool_use_tracker=tool_use_tracker,
+ server_conversation_tracker=server_conversation_tracker,
+ session=session,
+ session_items_to_rewind=(
+ last_saved_input_snapshot_for_rewind
+ if not is_resumed_state and session_persistence_enabled
+ else None
+ ),
+ reasoning_item_id_policy=resolved_reasoning_item_id_policy,
+ prompt_cache_key_resolver=prompt_cache_key_resolver,
)
)
- raise
- parallel_results: list[InputGuardrailResult] = []
- model_task = asyncio.create_task(
- run_single_turn(
- agent=current_agent,
+ if parallel_guardrails:
+ try:
+ parallel_results, turn_result = await asyncio.gather(
+ run_input_guardrails(
+ starting_agent,
+ parallel_guardrails,
+ copy_input_items(original_input),
+ context_wrapper,
+ ),
+ model_task,
+ )
+ except InputGuardrailTripwireTriggered:
+ if should_cancel_parallel_model_task_on_input_guardrail_trip():
+ if not model_task.done():
+ model_task.cancel()
+ await asyncio.gather(model_task, return_exceptions=True)
+ session_input_items_for_persistence = (
+ await persist_session_items_for_guardrail_trip(
+ session,
+ server_conversation_tracker,
+ session_input_items_for_persistence,
+ original_user_input,
+ run_state,
+ store=store_setting,
+ )
+ )
+ raise
+ else:
+ turn_result = await model_task
+
+ input_guardrail_results.extend(sequential_results)
+ input_guardrail_results.extend(parallel_results)
+ else:
+ turn_result = await run_single_turn(
+ bindings=current_bindings,
all_tools=all_tools,
original_input=original_input,
generated_items=items_for_model,
@@ -1033,61 +1247,14 @@ class AgentRunner:
else None
),
reasoning_item_id_policy=resolved_reasoning_item_id_policy,
+ prompt_cache_key_resolver=prompt_cache_key_resolver,
)
+ finally:
+ attach_usage_to_span(
+ current_turn_span,
+ usage_delta(turn_usage_start, context_wrapper.usage),
)
-
- if parallel_guardrails:
- try:
- parallel_results, turn_result = await asyncio.gather(
- run_input_guardrails(
- starting_agent,
- parallel_guardrails,
- copy_input_items(prepared_input),
- context_wrapper,
- ),
- model_task,
- )
- except InputGuardrailTripwireTriggered:
- if should_cancel_parallel_model_task_on_input_guardrail_trip():
- if not model_task.done():
- model_task.cancel()
- await asyncio.gather(model_task, return_exceptions=True)
- session_input_items_for_persistence = (
- await persist_session_items_for_guardrail_trip(
- session,
- server_conversation_tracker,
- session_input_items_for_persistence,
- original_user_input,
- run_state,
- store=store_setting,
- )
- )
- raise
- else:
- turn_result = await model_task
-
- input_guardrail_results.extend(sequential_results)
- input_guardrail_results.extend(parallel_results)
- else:
- turn_result = await run_single_turn(
- agent=current_agent,
- all_tools=all_tools,
- original_input=original_input,
- generated_items=items_for_model,
- hooks=hooks,
- context_wrapper=context_wrapper,
- run_config=run_config,
- should_run_agent_start_hooks=should_run_agent_start_hooks,
- tool_use_tracker=tool_use_tracker,
- server_conversation_tracker=server_conversation_tracker,
- session=session,
- session_items_to_rewind=(
- last_saved_input_snapshot_for_rewind
- if not is_resumed_state and session_persistence_enabled
- else None
- ),
- reasoning_item_id_policy=resolved_reasoning_item_id_policy,
- )
+ current_turn_span.finish(reset_current=True)
# Start hooks should only run on the first turn unless reset by a handoff.
last_saved_input_snapshot_for_rewind = None
@@ -1201,9 +1368,7 @@ class AgentRunner:
tool_output_guardrail_results=tool_output_guardrail_results,
context_wrapper=context_wrapper,
interruptions=[],
- _tool_use_tracker_snapshot=serialize_tool_use_tracker(
- tool_use_tracker
- ),
+ _tool_use_tracker_snapshot=_tool_use_tracker_snapshot(),
max_turns=max_turns,
)
result._current_turn = current_turn
@@ -1225,11 +1390,7 @@ class AgentRunner:
store=store_setting,
)
result._original_input = copy_input_items(original_input)
- return finalize_conversation_tracking(
- _with_reasoning_item_id_policy(result),
- server_conversation_tracker=server_conversation_tracker,
- run_state=run_state,
- )
+ return _finalize_result(result)
elif isinstance(turn_result.next_step, NextStepInterruption):
if session_persistence_enabled:
if not input_guardrails_triggered(input_guardrail_results):
@@ -1286,11 +1447,7 @@ class AgentRunner:
run_state=run_state,
original_input=original_input,
)
- return finalize_conversation_tracking(
- _with_reasoning_item_id_policy(result),
- server_conversation_tracker=server_conversation_tracker,
- run_state=run_state,
- )
+ return _finalize_result(result)
elif isinstance(turn_result.next_step, NextStepHandoff):
current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
if run_state is not None:
@@ -1324,24 +1481,64 @@ class AgentRunner:
# hold on to items from previous turns and to avoid leaking agent refs.
turn_result.pre_step_items.clear()
turn_result.new_step_items.clear()
- except AgentsException as exc:
- exc.run_data = RunErrorDetails(
- input=original_input,
- new_items=session_items,
- raw_responses=model_responses,
- last_agent=current_agent,
- context_wrapper=context_wrapper,
- input_guardrail_results=input_guardrail_results,
- output_guardrail_results=[],
- )
+ except BaseException as exc:
+ run_exception = exc
+ if isinstance(exc, AgentsException):
+ exc.run_data = RunErrorDetails(
+ input=original_input,
+ new_items=session_items,
+ raw_responses=model_responses,
+ last_agent=current_agent,
+ context_wrapper=context_wrapper,
+ input_guardrail_results=input_guardrail_results,
+ output_guardrail_results=[],
+ )
raise
finally:
+ try:
+ try:
+ memory_input = _sandbox_memory_input(
+ memory_input_items_for_persistence=memory_input_items_for_persistence,
+ original_user_input=original_user_input,
+ original_input=original_input,
+ )
+ if completed_result is not None:
+ await sandbox_runtime.enqueue_memory_result(
+ completed_result,
+ input_override=memory_input,
+ )
+ elif run_exception is not None:
+ current_step = getattr(run_state, "_current_step", None)
+ await sandbox_runtime.enqueue_memory_payload(
+ input=memory_input,
+ new_items=session_items,
+ final_output=None,
+ interruptions=approvals_from_step(current_step),
+ terminal_metadata=terminal_metadata_for_exception(run_exception),
+ )
+ except Exception as error:
+ logger.warning("Failed to enqueue sandbox memory after run: %s", error)
+ sandbox_resume_state = await sandbox_runtime.cleanup()
+ except Exception as error:
+ logger.warning("Failed to clean up sandbox resources after run: %s", error)
+ else:
+ if completed_result is not None:
+ completed_result._sandbox_resume_state = sandbox_resume_state
+ finally:
+ if completed_result is not None:
+ completed_result._sandbox_session = None
try:
await dispose_resolved_computers(run_context=context_wrapper)
except Exception as error:
logger.warning("Failed to dispose computers after run: %s", error)
if current_span:
current_span.finish(reset_current=True)
+ if current_task_span:
+ attach_usage_to_span(
+ current_task_span,
+ usage_delta(task_usage_start, context_wrapper.usage),
+ )
+ current_task_span.finish(reset_current=True)
def run_sync(
self,
@@ -1497,7 +1694,7 @@ class AgentRunner:
else:
# input is already str | list[TResponseInputItem] when not RunState
# Reuse input_for_result variable from outer scope
- input_for_result = cast(Union[str, list[TResponseInputItem]], input)
+ input_for_result = cast(str | list[TResponseInputItem], input)
validate_session_conversation_settings(
session,
conversation_id=conversation_id,
@@ -1550,9 +1747,21 @@ class AgentRunner:
if run_state is not None:
run_state.set_trace(new_trace or get_current_trace())
+ sandbox_runtime = SandboxRuntime(
+ starting_agent=starting_agent,
+ run_config=run_config,
+ rollout_id=_sandbox_memory_rollout_id(
+ run_config=run_config,
+ conversation_id=conversation_id,
+ session=session,
+ ),
+ run_state=run_state,
+ )
+
schema_agent = (
run_state._current_agent if run_state and run_state._current_agent else starting_agent
)
+ sandbox_runtime.assert_agent_supported(schema_agent)
output_schema = get_output_schema(schema_agent)
streamed_input: str | list[TResponseInputItem] = (
@@ -1618,6 +1827,8 @@ class AgentRunner:
streamed_result._state = run_state
if run_state is not None:
streamed_result._tool_use_tracker_snapshot = run_state.get_tool_use_tracker_snapshot()
+ if sandbox_runtime.enabled:
+ sandbox_runtime.apply_result_metadata(streamed_result)
# Kick off the actual agent loop in the background and return the streamed result object.
streamed_result.run_loop_task = asyncio.create_task(
@@ -1636,8 +1847,11 @@ class AgentRunner:
session=session,
run_state=run_state,
is_resumed_state=is_resumed_state,
+ sandbox_runtime=sandbox_runtime,
)
)
+ if sandbox_runtime.enabled:
+ streamed_result.ensure_sandbox_cleanup_on_completion()
return streamed_result
diff --git a/src/agents/run_config.py b/src/agents/run_config.py
index ad21f6c3..502aa729 100644
--- a/src/agents/run_config.py
+++ b/src/agents/run_config.py
@@ -1,8 +1,9 @@
from __future__ import annotations
import os
+from collections.abc import Callable
from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional
+from typing import TYPE_CHECKING, Any, Generic, Literal
from typing_extensions import NotRequired, TypedDict
@@ -22,9 +23,16 @@ from .util._types import MaybeAwaitable
if TYPE_CHECKING:
from .agent import Agent
from .run_context import RunContextWrapper
+ from .sandbox.manifest import Manifest
+ from .sandbox.session.base_sandbox_session import BaseSandboxSession
+ from .sandbox.session.sandbox_client import BaseSandboxClient
+ from .sandbox.session.sandbox_session_state import SandboxSessionState
+ from .sandbox.snapshot import SnapshotBase, SnapshotSpec
DEFAULT_MAX_TURNS = 10
+DEFAULT_MAX_MANIFEST_ENTRY_CONCURRENCY = 4
+DEFAULT_MAX_LOCAL_DIR_FILE_CONCURRENCY = 4
def _default_trace_include_sensitive_data() -> bool:
@@ -61,7 +69,7 @@ class ToolErrorFormatterArgs(Generic[TContext]):
kind: Literal["approval_rejected"]
"""The category of tool error being formatted."""
- tool_type: Literal["function", "computer", "shell", "apply_patch"]
+ tool_type: Literal["function", "computer", "shell", "apply_patch", "custom"]
"""The tool runtime that produced the error."""
tool_name: str
@@ -77,7 +85,56 @@ class ToolErrorFormatterArgs(Generic[TContext]):
"""The active run context for the current execution."""
-ToolErrorFormatter = Callable[[ToolErrorFormatterArgs[Any]], MaybeAwaitable[Optional[str]]]
+ToolErrorFormatter = Callable[[ToolErrorFormatterArgs[Any]], MaybeAwaitable[str | None]]
+
+
+@dataclass
+class SandboxConcurrencyLimits:
+ """Concurrency limits for sandbox materialization work."""
+
+ manifest_entries: int | None = DEFAULT_MAX_MANIFEST_ENTRY_CONCURRENCY
+ """Maximum number of manifest entries to materialize concurrently per sandbox session.
+
+ Set to `None` to disable this manifest entry limit.
+ """
+
+ local_dir_files: int | None = DEFAULT_MAX_LOCAL_DIR_FILE_CONCURRENCY
+ """Maximum number of files to copy concurrently for each local_dir manifest entry.
+
+ Set to `None` to disable this per-local-dir file copy limit.
+ """
+
+ def validate(self) -> None:
+ if self.manifest_entries is not None and self.manifest_entries < 1:
+ raise ValueError("concurrency_limits.manifest_entries must be at least 1")
+ if self.local_dir_files is not None and self.local_dir_files < 1:
+ raise ValueError("concurrency_limits.local_dir_files must be at least 1")
+
+
+@dataclass
+class SandboxRunConfig:
+ """Grouped sandbox runtime configuration for `Runner`."""
+
+ client: BaseSandboxClient[Any] | None = None
+ """Sandbox client used to create or resume sandbox sessions."""
+
+ options: Any | None = None
+ """Sandbox-client-specific options used when creating a fresh session."""
+
+ session: BaseSandboxSession | None = None
+ """Live sandbox session override for the current process."""
+
+ session_state: SandboxSessionState | None = None
+ """Explicit sandbox session state to resume from when not using `RunState` payloads."""
+
+ manifest: Manifest | None = None
+ """Optional sandbox manifest override for fresh session creation."""
+
+ snapshot: SnapshotSpec | SnapshotBase | None = None
+ """Optional sandbox snapshot used for fresh session creation."""
+
+ concurrency_limits: SandboxConcurrencyLimits = field(default_factory=SandboxConcurrencyLimits)
+ """Concurrency limits for sandbox materialization work."""
@dataclass
@@ -191,6 +248,9 @@ class RunConfig:
- ``"omit"`` strips reasoning item IDs from model input built by the runner.
"""
+ sandbox: SandboxRunConfig | None = None
+ """Optional sandbox runtime configuration for `SandboxAgent` execution."""
+
class RunOptions(TypedDict, Generic[TContext]):
"""Arguments for ``AgentRunner`` methods."""
@@ -231,6 +291,8 @@ __all__ = [
"ReasoningItemIdPolicy",
"RunConfig",
"RunOptions",
+ "SandboxConcurrencyLimits",
+ "SandboxRunConfig",
"ToolErrorFormatter",
"ToolErrorFormatterArgs",
"_default_trace_include_sensitive_data",
diff --git a/src/agents/run_error_handlers.py b/src/agents/run_error_handlers.py
index c402de0d..aee386fb 100644
--- a/src/agents/run_error_handlers.py
+++ b/src/agents/run_error_handlers.py
@@ -1,7 +1,8 @@
from __future__ import annotations
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any, Callable, Generic, Union
+from typing import Any, Generic
from typing_extensions import TypedDict
@@ -42,7 +43,7 @@ class RunErrorHandlerResult:
# Handlers may return RunErrorHandlerResult, a dict with final_output, or a raw final output value.
RunErrorHandler = Callable[
[RunErrorHandlerInput[TContext]],
- MaybeAwaitable[Union[RunErrorHandlerResult, dict[str, Any], Any, None]],
+ MaybeAwaitable[RunErrorHandlerResult | dict[str, Any] | Any | None],
]
diff --git a/src/agents/run_internal/_asyncio_progress.py b/src/agents/run_internal/_asyncio_progress.py
index 2bc135f2..8b327060 100644
--- a/src/agents/run_internal/_asyncio_progress.py
+++ b/src/agents/run_internal/_asyncio_progress.py
@@ -51,7 +51,7 @@ def _get_sleep_deadline_from_awaitable(
return float(when())
delay = frame.f_locals.get("delay")
- if isinstance(delay, (int, float)):
+ if isinstance(delay, int | float):
return loop.time() if delay <= 0 else loop.time() + float(delay)
return None
diff --git a/src/agents/run_internal/agent_bindings.py b/src/agents/run_internal/agent_bindings.py
new file mode 100644
index 00000000..93e3702b
--- /dev/null
+++ b/src/agents/run_internal/agent_bindings.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Generic
+
+from ..agent import Agent
+from ..run_context import TContext
+
+__all__ = [
+ "AgentBindings",
+ "bind_execution_agent",
+ "bind_public_agent",
+]
+
+
+@dataclass(frozen=True)
+class AgentBindings(Generic[TContext]):
+ """Carry the public and execution agent identities for a turn."""
+
+ public_agent: Agent[TContext]
+ execution_agent: Agent[TContext]
+
+
+def bind_public_agent(agent: Agent[TContext]) -> AgentBindings[TContext]:
+ """Build bindings for non-rewritten execution where both identities are the same."""
+ return AgentBindings(public_agent=agent, execution_agent=agent)
+
+
+def bind_execution_agent(
+ *,
+ public_agent: Agent[TContext],
+ execution_agent: Agent[TContext],
+) -> AgentBindings[TContext]:
+ """Build bindings for execution-only clones such as sandbox-prepared agents."""
+ return AgentBindings(
+ public_agent=public_agent,
+ execution_agent=execution_agent,
+ )
diff --git a/src/agents/run_internal/agent_runner_helpers.py b/src/agents/run_internal/agent_runner_helpers.py
index 776e4067..e79f7ba6 100644
--- a/src/agents/run_internal/agent_runner_helpers.py
+++ b/src/agents/run_internal/agent_runner_helpers.py
@@ -4,19 +4,29 @@ from __future__ import annotations
from typing import Any, cast
+from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
+
from ..agent import Agent
from ..agent_tool_state import set_agent_tool_state_scope
from ..exceptions import UserError
from ..guardrail import InputGuardrailResult
from ..items import ModelResponse, RunItem, ToolApprovalItem, TResponseInputItem
from ..memory import Session
+from ..models.openai_agent_registration import add_openai_harness_id_to_metadata
from ..result import RunResult
from ..run_config import RunConfig
from ..run_context import RunContextWrapper, TContext
from ..run_state import RunState
from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult
+from ..tracing import Span
from ..tracing.config import TracingConfig
from ..tracing.traces import TraceState
+from ..usage import (
+ Usage,
+ task_usage_to_span_data,
+ total_usage_to_span_metadata,
+ turn_usage_to_span_data,
+)
from .items import copy_input_items
from .oai_conversation import OpenAIServerConversationTracker
from .run_steps import (
@@ -32,6 +42,7 @@ from .tool_use_tracker import AgentToolUseTracker, serialize_tool_use_tracker
__all__ = [
"apply_resumed_conversation_settings",
"append_model_response_if_new",
+ "attach_usage_to_span",
"build_generated_items_details",
"build_interruption_result",
"build_resumed_stream_debug_extra",
@@ -53,10 +64,96 @@ _PARALLEL_INPUT_GUARDRAIL_CANCEL_PATCH_ID = (
)
+def snapshot_usage(usage: Usage) -> Usage:
+ """Create a usage snapshot for computing invocation-local deltas."""
+ return Usage(
+ requests=usage.requests,
+ input_tokens=usage.input_tokens,
+ output_tokens=usage.output_tokens,
+ total_tokens=usage.total_tokens,
+ input_tokens_details=InputTokensDetails(
+ cached_tokens=(
+ usage.input_tokens_details.cached_tokens
+ if usage.input_tokens_details and usage.input_tokens_details.cached_tokens
+ else 0
+ )
+ ),
+ output_tokens_details=OutputTokensDetails(
+ reasoning_tokens=(
+ usage.output_tokens_details.reasoning_tokens
+ if usage.output_tokens_details and usage.output_tokens_details.reasoning_tokens
+ else 0
+ )
+ ),
+ )
+
+
+def usage_delta(start: Usage, end: Usage) -> Usage:
+ """Return the aggregate usage added between two snapshots."""
+ return Usage(
+ requests=end.requests - start.requests,
+ input_tokens=end.input_tokens - start.input_tokens,
+ output_tokens=end.output_tokens - start.output_tokens,
+ total_tokens=end.total_tokens - start.total_tokens,
+ input_tokens_details=InputTokensDetails(
+ cached_tokens=(
+ (end.input_tokens_details.cached_tokens or 0)
+ - (start.input_tokens_details.cached_tokens or 0)
+ )
+ ),
+ output_tokens_details=OutputTokensDetails(
+ reasoning_tokens=(
+ (end.output_tokens_details.reasoning_tokens or 0)
+ - (start.output_tokens_details.reasoning_tokens or 0)
+ )
+ ),
+ )
+
+
+def attach_usage_to_span(
+ span: Span[Any] | None,
+ usage: Usage,
+) -> None:
+ """Attach aggregate token usage to a span export metadata bag."""
+ cached_tokens = (
+ usage.input_tokens_details.cached_tokens
+ if usage.input_tokens_details and usage.input_tokens_details.cached_tokens
+ else 0
+ )
+ reasoning_tokens = (
+ usage.output_tokens_details.reasoning_tokens
+ if usage.output_tokens_details and usage.output_tokens_details.reasoning_tokens
+ else 0
+ )
+ if span is None or (
+ usage.requests == 0
+ and usage.input_tokens == 0
+ and usage.output_tokens == 0
+ and usage.total_tokens == 0
+ and cached_tokens == 0
+ and reasoning_tokens == 0
+ ):
+ return
+
+ if span.span_data.type == "turn":
+ span.span_data.usage = turn_usage_to_span_data(usage)
+ return
+
+ if span.span_data.type == "task":
+ span.span_data.usage = task_usage_to_span_data(usage)
+ return
+
+ metadata = dict(getattr(span.span_data, "metadata", None) or {})
+ metadata["usage"] = total_usage_to_span_metadata(usage)
+ span.span_data.metadata = metadata
+
+
def should_cancel_parallel_model_task_on_input_guardrail_trip() -> bool:
"""Return whether an in-flight model task should be cancelled on guardrail trip."""
try:
- from temporalio import workflow as temporal_workflow # type: ignore[import-not-found]
+ from temporalio import (
+ workflow as temporal_workflow, # type: ignore[import-not-found,unused-ignore]
+ )
except Exception:
return True
@@ -131,6 +228,11 @@ def resolve_trace_settings(
if tracing is None and trace_state.tracing_api_key:
tracing = {"api_key": trace_state.tracing_api_key}
+ metadata = add_openai_harness_id_to_metadata(
+ metadata,
+ model_provider=run_config.model_provider,
+ )
+
return workflow_name, trace_id, group_id, metadata, tracing
@@ -253,6 +355,11 @@ def build_interruption_result(
original_input: str | list[TResponseInputItem],
) -> RunResult:
"""Create a RunResult for an interruption path."""
+ identity_root_agent = (
+ run_state._starting_agent
+ if run_state is not None and run_state._starting_agent is not None
+ else current_agent
+ )
result = RunResult(
input=result_input,
new_items=session_items,
@@ -266,7 +373,10 @@ def build_interruption_result(
context_wrapper=context_wrapper,
interruptions=interruptions,
_last_processed_response=processed_response,
- _tool_use_tracker_snapshot=serialize_tool_use_tracker(tool_use_tracker),
+ _tool_use_tracker_snapshot=serialize_tool_use_tracker(
+ tool_use_tracker,
+ starting_agent=identity_root_agent,
+ ),
max_turns=max_turns,
)
result._current_turn = current_turn
diff --git a/src/agents/run_internal/error_handlers.py b/src/agents/run_internal/error_handlers.py
index e2b16905..bcb2d9bc 100644
--- a/src/agents/run_internal/error_handlers.py
+++ b/src/agents/run_internal/error_handlers.py
@@ -69,7 +69,7 @@ def format_final_output_text(agent: Agent[Any], final_output: Any) -> str:
payload_bytes = output_schema._type_adapter.dump_json(payload_value)
return (
payload_bytes.decode()
- if isinstance(payload_bytes, (bytes, bytearray))
+ if isinstance(payload_bytes, bytes | bytearray)
else str(payload_bytes)
)
return json.dumps(payload_value, ensure_ascii=False)
@@ -92,7 +92,7 @@ def validate_handler_final_output(agent: Agent[Any], final_output: Any) -> Any:
payload_bytes = output_schema._type_adapter.dump_json(payload_value)
payload = (
payload_bytes.decode()
- if isinstance(payload_bytes, (bytes, bytearray))
+ if isinstance(payload_bytes, bytes | bytearray)
else str(payload_bytes)
)
else:
diff --git a/src/agents/run_internal/guardrails.py b/src/agents/run_internal/guardrails.py
index 375cc37c..1b04779d 100644
--- a/src/agents/run_internal/guardrails.py
+++ b/src/agents/run_internal/guardrails.py
@@ -57,7 +57,7 @@ async def run_input_guardrails_with_queue(
input: str | list[TResponseInputItem],
context: RunContextWrapper[TContext],
streamed_result: RunResultStreaming,
- parent_span: Span[Any],
+ parent_span: Span[Any] | None,
) -> None:
"""Run guardrails concurrently and stream results into the queue."""
queue = streamed_result._input_guardrail_queue
@@ -74,16 +74,18 @@ async def run_input_guardrails_with_queue(
for t in guardrail_tasks:
t.cancel()
await asyncio.gather(*guardrail_tasks, return_exceptions=True)
- _error_tracing.attach_error_to_span(
- parent_span,
- SpanError(
- message="Guardrail tripwire triggered",
- data={
- "guardrail": result.guardrail.get_name(),
- "type": "input_guardrail",
- },
- ),
+ span_error = SpanError(
+ message="Guardrail tripwire triggered",
+ data={
+ "guardrail": result.guardrail.get_name(),
+ "type": "input_guardrail",
+ },
)
+ if parent_span is not None:
+ _error_tracing.attach_error_to_span(parent_span, span_error)
+ else:
+ # Early first-turn streamed guardrails can run before the agent span exists.
+ _error_tracing.attach_error_to_current_span(span_error)
queue.put_nowait(result)
guardrail_results.append(result)
break
diff --git a/src/agents/run_internal/items.py b/src/agents/run_internal/items.py
index 3e0693b0..f1659614 100644
--- a/src/agents/run_internal/items.py
+++ b/src/agents/run_internal/items.py
@@ -22,6 +22,7 @@ TOOL_CALL_SESSION_DESCRIPTION_KEY = "_agents_tool_description"
TOOL_CALL_SESSION_TITLE_KEY = "_agents_tool_title"
_TOOL_CALL_TO_OUTPUT_TYPE: dict[str, str] = {
"function_call": "function_call_output",
+ "custom_tool_call": "custom_tool_call_output",
"shell_call": "shell_call_output",
"apply_patch_call": "apply_patch_call_output",
"computer_call": "computer_call_output",
@@ -342,15 +343,19 @@ def apply_patch_rejection_item(
agent: Any,
call_id: str,
*,
+ output_type: Literal["apply_patch_call_output", "custom_tool_call_output"] = (
+ "apply_patch_call_output"
+ ),
rejection_message: str = REJECTION_MESSAGE,
) -> ToolCallOutputItem:
"""Build a ToolCallOutputItem representing a rejected apply_patch call."""
rejection_raw_item: dict[str, Any] = {
- "type": "apply_patch_call_output",
+ "type": output_type,
"call_id": call_id,
- "status": "failed",
"output": rejection_message,
}
+ if output_type == "apply_patch_call_output":
+ rejection_raw_item["status"] = "failed"
return ToolCallOutputItem(
agent=agent,
output=rejection_message,
diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py
index e32d74b4..289daca0 100644
--- a/src/agents/run_internal/model_retry.py
+++ b/src/agents/run_internal/model_retry.py
@@ -80,7 +80,7 @@ def _extract_headers(error: Exception) -> httpx.Headers | Mapping[str, str] | No
for attr_name in ("headers", "response_headers"):
headers = getattr(candidate, attr_name, None)
- if isinstance(headers, (httpx.Headers, Mapping)):
+ if isinstance(headers, httpx.Headers | Mapping):
return headers
return None
@@ -172,7 +172,7 @@ def _is_abort_like_error(error: Exception) -> bool:
def _is_network_like_error(error: Exception) -> bool:
- if isinstance(error, (APIConnectionError, APITimeoutError, TimeoutError)):
+ if isinstance(error, APIConnectionError | APITimeoutError | TimeoutError):
return True
network_error_types = (
@@ -215,7 +215,7 @@ def _normalize_retry_error(
is_abort=_is_abort_like_error(error),
is_network_error=_is_network_like_error(error),
is_timeout=any(
- isinstance(candidate, (APITimeoutError, TimeoutError))
+ isinstance(candidate, APITimeoutError | TimeoutError)
for candidate in _iter_error_chain(error)
),
)
@@ -663,7 +663,7 @@ async def stream_response_with_retry(
return
except BaseException as error:
await _close_async_iterator_quietly(stream)
- if isinstance(error, (asyncio.CancelledError, GeneratorExit)):
+ if isinstance(error, asyncio.CancelledError | GeneratorExit):
raise
if not isinstance(error, Exception):
raise
diff --git a/src/agents/run_internal/oai_conversation.py b/src/agents/run_internal/oai_conversation.py
index 0f6a9b1a..233898d5 100644
--- a/src/agents/run_internal/oai_conversation.py
+++ b/src/agents/run_internal/oai_conversation.py
@@ -418,7 +418,7 @@ class OpenAIServerConversationTracker:
self._register_prepared_item_source(prepared_item, source_item)
filtered_initials = []
for item in initial_items:
- if item is None or isinstance(item, (str, bytes)):
+ if item is None or isinstance(item, str | bytes):
continue
filtered_initials.append(item)
self.remaining_initial_input = filtered_initials or None
diff --git a/src/agents/run_internal/prompt_cache_key.py b/src/agents/run_internal/prompt_cache_key.py
new file mode 100644
index 00000000..7fc99e28
--- /dev/null
+++ b/src/agents/run_internal/prompt_cache_key.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass, replace as dataclass_replace
+from hashlib import sha256
+from typing import Any
+
+from ..memory import Session
+from ..model_settings import ModelSettings
+from ..run_state import RunState
+from .run_grouping import RunGroupingKind, resolve_run_grouping
+
+PROMPT_CACHE_KEY_FIELD = "prompt_cache_key"
+
+
+@dataclass
+class PromptCacheKeyResolver:
+ """Provides one generated prompt cache key for a runner invocation.
+
+ The runner asks for a key on every model turn. This helper returns the same generated key each
+ time, persists it to RunState for resume flows, and opts out when the request already forwards
+ a user-supplied key through ModelSettings.
+ """
+
+ run_state: RunState[Any] | None = None
+ _generated_key: str | None = None
+
+ @classmethod
+ def from_run_state(
+ cls,
+ *,
+ run_state: RunState[Any] | None,
+ ) -> PromptCacheKeyResolver:
+ return cls(
+ run_state=run_state,
+ _generated_key=(
+ run_state._generated_prompt_cache_key if run_state is not None else None
+ ),
+ )
+
+ def resolve(
+ self,
+ model_settings: ModelSettings,
+ *,
+ model: object,
+ conversation_id: str | None,
+ session: Session | None,
+ group_id: str | None,
+ ) -> str | None:
+ """Return the generated prompt cache key for this model call.
+
+ Returns None when the runner should not add one.
+ """
+ # A prompt_cache_key in ModelSettings extras is already forwarded to the model adapter, so
+ # the runner should not also generate one.
+ if _model_settings_has_prompt_cache_key(model_settings):
+ return None
+
+ if not _model_supports_default_prompt_cache_key(model):
+ return None
+
+ return self._get_or_create_generated_key(
+ conversation_id=conversation_id,
+ session=session,
+ group_id=group_id,
+ )
+
+ def _get_or_create_generated_key(
+ self,
+ *,
+ conversation_id: str | None,
+ session: Session | None,
+ group_id: str | None,
+ ) -> str:
+ if self._generated_key is not None:
+ return self._generated_key
+
+ grouping_kind, grouping_value = resolve_run_grouping(
+ conversation_id=conversation_id,
+ session=session,
+ group_id=group_id,
+ )
+ key = _prompt_cache_key_for_grouping(grouping_kind, grouping_value)
+
+ self._generated_key = key
+ if self.run_state is not None:
+ self.run_state._generated_prompt_cache_key = key
+ return key
+
+
+def _model_settings_has_prompt_cache_key(model_settings: ModelSettings) -> bool:
+ return _mapping_has_prompt_cache_key(
+ model_settings.extra_args
+ ) or _mapping_has_prompt_cache_key(model_settings.extra_body)
+
+
+def model_settings_with_prompt_cache_key(
+ model_settings: ModelSettings,
+ prompt_cache_key: str | None,
+) -> ModelSettings:
+ """Return model settings with the generated prompt cache key added to extra_args."""
+ if prompt_cache_key is None or _model_settings_has_prompt_cache_key(model_settings):
+ return model_settings
+
+ extra_args = dict(model_settings.extra_args or {})
+ extra_args[PROMPT_CACHE_KEY_FIELD] = prompt_cache_key
+ return dataclass_replace(model_settings, extra_args=extra_args)
+
+
+def _model_supports_default_prompt_cache_key(model: object) -> bool:
+ supports_default = getattr(model, "_supports_default_prompt_cache_key", None)
+ return bool(supports_default()) if callable(supports_default) else False
+
+
+def _mapping_has_prompt_cache_key(value: object) -> bool:
+ return isinstance(value, Mapping) and PROMPT_CACHE_KEY_FIELD in value
+
+
+def _hashed_key(kind: str, value: str) -> str:
+ digest = sha256(value.encode("utf-8")).hexdigest()[:32]
+ return f"agents-sdk:{kind}:{digest}"
+
+
+def _prompt_cache_key_for_grouping(kind: RunGroupingKind, value: str) -> str:
+ if kind == "run":
+ # With no conversation, session, or group id, reuse the key only inside this run. That
+ # helps multi-turn agent loops without pretending unrelated Runner.run() calls are part
+ # of the same cache group.
+ return f"agents-sdk:run:{value}"
+ return _hashed_key(kind, value)
diff --git a/src/agents/run_internal/run_grouping.py b/src/agents/run_internal/run_grouping.py
new file mode 100644
index 00000000..acf859ba
--- /dev/null
+++ b/src/agents/run_internal/run_grouping.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from typing import Literal
+from uuid import uuid4
+
+from ..memory import Session
+
+RunGroupingKind = Literal["conversation", "session", "group", "run"]
+RunGrouping = tuple[RunGroupingKind, str]
+
+
+def resolve_run_grouping(
+ *,
+ conversation_id: str | None,
+ session: Session | None,
+ group_id: str | None,
+) -> RunGrouping:
+ """Resolve the runner's stable grouping hierarchy.
+
+ The order matches prompt-cache grouping: server conversation, SDK session, trace group,
+ then a generated per-run value.
+ """
+
+ if conversation_id is not None and conversation_id.strip():
+ return "conversation", conversation_id.strip()
+
+ session_id = get_session_id_if_available(session)
+ if session_id is not None:
+ return "session", session_id
+
+ if group_id is not None and group_id.strip():
+ return "group", group_id.strip()
+
+ return "run", uuid4().hex
+
+
+def resolve_run_grouping_id(
+ *,
+ conversation_id: str | None,
+ session: Session | None,
+ group_id: str | None,
+) -> str:
+ kind, value = resolve_run_grouping(
+ conversation_id=conversation_id,
+ session=session,
+ group_id=group_id,
+ )
+ return f"run-{value}" if kind == "run" else value
+
+
+def get_session_id_if_available(session: Session | None) -> str | None:
+ if session is None:
+ return None
+ try:
+ session_id = session.session_id
+ except Exception:
+ return None
+ session_id = session_id.strip()
+ return session_id if session_id else None
diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py
index 36e34b4f..02f191ce 100644
--- a/src/agents/run_internal/run_loop.py
+++ b/src/agents/run_internal/run_loop.py
@@ -58,18 +58,25 @@ from ..run_config import ReasoningItemIdPolicy, RunConfig
from ..run_context import AgentHookContext, RunContextWrapper, TContext
from ..run_error_handlers import RunErrorHandlers
from ..run_state import RunState
+from ..sandbox.runtime import SandboxRuntime
from ..stream_events import (
AgentUpdatedStreamEvent,
RawResponsesStreamEvent,
RunItemStreamEvent,
)
from ..tool import FunctionTool, Tool, dispose_resolved_computers
-from ..tracing import Span, SpanError, agent_span, get_current_trace
+from ..tracing import Span, SpanError, agent_span, get_current_trace, task_span, turn_span
from ..tracing.model_tracing import get_model_tracing_impl
-from ..tracing.span_data import AgentSpanData
+from ..tracing.span_data import AgentSpanData, TaskSpanData
from ..usage import Usage
from ..util import _coro, _error_tracing
-from .agent_runner_helpers import apply_resumed_conversation_settings
+from .agent_bindings import AgentBindings, bind_public_agent
+from .agent_runner_helpers import (
+ apply_resumed_conversation_settings,
+ attach_usage_to_span,
+ snapshot_usage,
+ usage_delta,
+)
from .approvals import approvals_from_step
from .error_handlers import (
build_run_error_data,
@@ -101,6 +108,7 @@ from .model_retry import (
stream_response_with_retry,
)
from .oai_conversation import OpenAIServerConversationTracker
+from .prompt_cache_key import PromptCacheKeyResolver, model_settings_with_prompt_cache_key
from .run_steps import (
NextStepFinalOutput,
NextStepHandoff,
@@ -234,11 +242,7 @@ __all__ = [
def _should_attach_generic_agent_error(exc: Exception) -> bool:
return not isinstance(
exc,
- (
- ModelBehaviorError,
- InputGuardrailTripwireTriggered,
- OutputGuardrailTripwireTriggered,
- ),
+ ModelBehaviorError | InputGuardrailTripwireTriggered | OutputGuardrailTripwireTriggered,
)
@@ -430,6 +434,7 @@ async def start_streaming(
run_state: RunState[TContext] | None = None,
*,
is_resumed_state: bool = False,
+ sandbox_runtime: SandboxRuntime[TContext] | None = None,
):
"""Run the streaming loop for a run result."""
if streamed_result.trace:
@@ -450,171 +455,258 @@ async def start_streaming(
auto_previous_response_id=auto_previous_response_id,
)
- resolved_reasoning_item_id_policy: ReasoningItemIdPolicy | None = (
- run_config.reasoning_item_id_policy
- if run_config.reasoning_item_id_policy is not None
- else (run_state._reasoning_item_id_policy if run_state is not None else None)
+ current_trace = streamed_result.trace or get_current_trace()
+ current_task_span: Span[TaskSpanData] | None = (
+ task_span(name=current_trace.name) if current_trace else None
)
- if run_state is not None:
- run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy
- streamed_result._reasoning_item_id_policy = resolved_reasoning_item_id_policy
+ if current_task_span:
+ current_task_span.start(mark_as_current=True)
+ task_usage_start = snapshot_usage(context_wrapper.usage)
- if conversation_id is not None or previous_response_id is not None or auto_previous_response_id:
- server_conversation_tracker = OpenAIServerConversationTracker(
- conversation_id=conversation_id,
- previous_response_id=previous_response_id,
- auto_previous_response_id=auto_previous_response_id,
- reasoning_item_id_policy=resolved_reasoning_item_id_policy,
+ try:
+ resolved_reasoning_item_id_policy: ReasoningItemIdPolicy | None = (
+ run_config.reasoning_item_id_policy
+ if run_config.reasoning_item_id_policy is not None
+ else (run_state._reasoning_item_id_policy if run_state is not None else None)
)
- else:
- server_conversation_tracker = None
-
- def _sync_conversation_tracking_from_tracker() -> None:
- if server_conversation_tracker is None:
- return
if run_state is not None:
- run_state._conversation_id = server_conversation_tracker.conversation_id
- run_state._previous_response_id = server_conversation_tracker.previous_response_id
- run_state._auto_previous_response_id = (
+ run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy
+ streamed_result._reasoning_item_id_policy = resolved_reasoning_item_id_policy
+
+ if (
+ conversation_id is not None
+ or previous_response_id is not None
+ or auto_previous_response_id
+ ):
+ server_conversation_tracker = OpenAIServerConversationTracker(
+ conversation_id=conversation_id,
+ previous_response_id=previous_response_id,
+ auto_previous_response_id=auto_previous_response_id,
+ reasoning_item_id_policy=resolved_reasoning_item_id_policy,
+ )
+ else:
+ server_conversation_tracker = None
+
+ def _sync_conversation_tracking_from_tracker() -> None:
+ if server_conversation_tracker is None:
+ return
+ if run_state is not None:
+ run_state._conversation_id = server_conversation_tracker.conversation_id
+ run_state._previous_response_id = server_conversation_tracker.previous_response_id
+ run_state._auto_previous_response_id = (
+ server_conversation_tracker.auto_previous_response_id
+ )
+ streamed_result._conversation_id = server_conversation_tracker.conversation_id
+ streamed_result._previous_response_id = server_conversation_tracker.previous_response_id
+ streamed_result._auto_previous_response_id = (
server_conversation_tracker.auto_previous_response_id
)
- streamed_result._conversation_id = server_conversation_tracker.conversation_id
- streamed_result._previous_response_id = server_conversation_tracker.previous_response_id
- streamed_result._auto_previous_response_id = (
- server_conversation_tracker.auto_previous_response_id
+
+ if run_state is None:
+ run_state = RunState(
+ context=context_wrapper,
+ original_input=copy_input_items(starting_input),
+ starting_agent=starting_agent,
+ max_turns=max_turns,
+ conversation_id=conversation_id,
+ previous_response_id=previous_response_id,
+ auto_previous_response_id=auto_previous_response_id,
+ )
+ run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy
+ streamed_result._state = run_state
+ elif streamed_result._state is None:
+ streamed_result._state = run_state
+ if run_state is not None:
+ streamed_result._model_input_items = list(run_state._generated_items)
+ # Streamed follow-ups need the same normalized replay signal as sync runs when the
+ # runner's continuation differs from the richer session history.
+ streamed_result._replay_from_model_input_items = list(
+ run_state._generated_items
+ ) != list(run_state._session_items)
+
+ if run_state is not None:
+ run_state._conversation_id = conversation_id
+ run_state._previous_response_id = previous_response_id
+ run_state._auto_previous_response_id = auto_previous_response_id
+ streamed_result._conversation_id = conversation_id
+ streamed_result._previous_response_id = previous_response_id
+ streamed_result._auto_previous_response_id = auto_previous_response_id
+ prompt_cache_key_resolver = PromptCacheKeyResolver.from_run_state(
+ run_state=run_state,
)
- if run_state is None:
- run_state = RunState(
- context=context_wrapper,
- original_input=copy_input_items(starting_input),
- starting_agent=starting_agent,
- max_turns=max_turns,
- conversation_id=conversation_id,
- previous_response_id=previous_response_id,
- auto_previous_response_id=auto_previous_response_id,
- )
- run_state._reasoning_item_id_policy = resolved_reasoning_item_id_policy
- streamed_result._state = run_state
- elif streamed_result._state is None:
- streamed_result._state = run_state
- if run_state is not None:
- streamed_result._model_input_items = list(run_state._generated_items)
- # Streamed follow-ups need the same normalized replay signal as sync runs when the
- # runner's continuation differs from the richer session history.
- streamed_result._replay_from_model_input_items = list(run_state._generated_items) != list(
- run_state._session_items
- )
+ current_span: Span[AgentSpanData] | None = None
+ if run_state is not None and run_state._current_agent is not None:
+ current_agent = run_state._current_agent
+ else:
+ current_agent = starting_agent
+ if run_state is not None:
+ current_turn = run_state._current_turn
+ else:
+ current_turn = 0
+ should_run_agent_start_hooks = True
+ tool_use_tracker = AgentToolUseTracker()
+ if run_state is not None:
+ hydrate_tool_use_tracker(tool_use_tracker, run_state, starting_agent)
- if run_state is not None:
- run_state._conversation_id = conversation_id
- run_state._previous_response_id = previous_response_id
- run_state._auto_previous_response_id = auto_previous_response_id
- streamed_result._conversation_id = conversation_id
- streamed_result._previous_response_id = previous_response_id
- streamed_result._auto_previous_response_id = auto_previous_response_id
+ pending_server_items: list[RunItem] | None = None
+ session_input_items_for_persistence: list[TResponseInputItem] | None = None
- current_span: Span[AgentSpanData] | None = None
- if run_state is not None and run_state._current_agent is not None:
- current_agent = run_state._current_agent
- else:
- current_agent = starting_agent
- if run_state is not None:
- current_turn = run_state._current_turn
- else:
- current_turn = 0
- should_run_agent_start_hooks = True
- tool_use_tracker = AgentToolUseTracker()
- if run_state is not None:
- hydrate_tool_use_tracker(tool_use_tracker, run_state, starting_agent)
+ if is_resumed_state and server_conversation_tracker is not None and run_state is not None:
+ session_items: list[TResponseInputItem] | None = None
+ if session is not None:
+ try:
+ session_items = await session.get_items()
+ except Exception:
+ session_items = None
+ server_conversation_tracker.hydrate_from_state(
+ original_input=run_state._original_input,
+ generated_items=run_state._generated_items,
+ model_responses=run_state._model_responses,
+ session_items=session_items,
+ )
- pending_server_items: list[RunItem] | None = None
- session_input_items_for_persistence: list[TResponseInputItem] | None = None
+ streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent))
- if is_resumed_state and server_conversation_tracker is not None and run_state is not None:
- session_items: list[TResponseInputItem] | None = None
- if session is not None:
- try:
- session_items = await session.get_items()
- except Exception:
- session_items = None
- server_conversation_tracker.hydrate_from_state(
- original_input=run_state._original_input,
- generated_items=run_state._generated_items,
- model_responses=run_state._model_responses,
- session_items=session_items,
- )
-
- streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent))
-
- prepared_input: str | list[TResponseInputItem]
- if is_resumed_state and run_state is not None:
- prepared_input = normalize_resumed_input(starting_input)
- streamed_result.input = prepared_input
- streamed_result._original_input_for_persistence = []
- streamed_result._stream_input_persisted = True
- else:
- server_manages_conversation = server_conversation_tracker is not None
- prepared_input, session_items_snapshot = await prepare_input_with_session(
- starting_input,
- session,
- run_config.session_input_callback,
- run_config.session_settings,
- include_history_in_prepared_input=not server_manages_conversation,
- preserve_dropped_new_items=True,
- )
- streamed_result.input = prepared_input
- streamed_result._original_input = copy_input_items(prepared_input)
- if server_manages_conversation:
+ prepared_input: str | list[TResponseInputItem]
+ if is_resumed_state and run_state is not None:
+ prepared_input = normalize_resumed_input(starting_input)
+ streamed_result.input = prepared_input
streamed_result._original_input_for_persistence = []
streamed_result._stream_input_persisted = True
else:
- session_input_items_for_persistence = session_items_snapshot
- streamed_result._original_input_for_persistence = session_items_snapshot
+ server_manages_conversation = server_conversation_tracker is not None
+ prepared_input, session_items_snapshot = await prepare_input_with_session(
+ starting_input,
+ session,
+ run_config.session_input_callback,
+ run_config.session_settings,
+ include_history_in_prepared_input=not server_manages_conversation,
+ preserve_dropped_new_items=True,
+ )
+ streamed_result.input = prepared_input
+ streamed_result._original_input = copy_input_items(prepared_input)
+ if server_manages_conversation:
+ streamed_result._original_input_for_persistence = []
+ streamed_result._stream_input_persisted = True
+ else:
+ session_input_items_for_persistence = session_items_snapshot
+ streamed_result._original_input_for_persistence = session_items_snapshot
- async def _save_resumed_items(
- items: list[RunItem], response_id: str | None, store_setting: bool | None
- ) -> None:
- await _save_resumed_stream_items(
- session=session,
- server_conversation_tracker=server_conversation_tracker,
- streamed_result=streamed_result,
- run_state=run_state,
- items=items,
- response_id=response_id,
- store=store_setting,
- )
+ async def _save_resumed_items(
+ items: list[RunItem], response_id: str | None, store_setting: bool | None
+ ) -> None:
+ await _save_resumed_stream_items(
+ session=session,
+ server_conversation_tracker=server_conversation_tracker,
+ streamed_result=streamed_result,
+ run_state=run_state,
+ items=items,
+ response_id=response_id,
+ store=store_setting,
+ )
- async def _save_stream_items_with_count(
- items: list[RunItem], response_id: str | None, store_setting: bool | None
- ) -> None:
- await _save_stream_items(
- session=session,
- server_conversation_tracker=server_conversation_tracker,
- streamed_result=streamed_result,
- run_state=run_state,
- items=items,
- response_id=response_id,
- update_persisted_count=True,
- store=store_setting,
- )
+ async def _save_stream_items_with_count(
+ items: list[RunItem], response_id: str | None, store_setting: bool | None
+ ) -> None:
+ await _save_stream_items(
+ session=session,
+ server_conversation_tracker=server_conversation_tracker,
+ streamed_result=streamed_result,
+ run_state=run_state,
+ items=items,
+ response_id=response_id,
+ update_persisted_count=True,
+ store=store_setting,
+ )
- async def _save_stream_items_without_count(
- items: list[RunItem], response_id: str | None, store_setting: bool | None
- ) -> None:
- await _save_stream_items(
- session=session,
- server_conversation_tracker=server_conversation_tracker,
- streamed_result=streamed_result,
- run_state=run_state,
- items=items,
- response_id=response_id,
- update_persisted_count=False,
- store=store_setting,
- )
+ async def _save_stream_items_without_count(
+ items: list[RunItem], response_id: str | None, store_setting: bool | None
+ ) -> None:
+ await _save_stream_items(
+ session=session,
+ server_conversation_tracker=server_conversation_tracker,
+ streamed_result=streamed_result,
+ run_state=run_state,
+ items=items,
+ response_id=response_id,
+ update_persisted_count=False,
+ store=store_setting,
+ )
+ except BaseException:
+ if current_task_span:
+ attach_usage_to_span(
+ current_task_span,
+ usage_delta(task_usage_start, context_wrapper.usage),
+ )
+ current_task_span.finish(reset_current=True)
+ if streamed_result.trace:
+ streamed_result.trace.finish(reset_current=True)
+ if not streamed_result.is_complete:
+ streamed_result.is_complete = True
+ streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
+ raise
try:
while True:
+ all_input_guardrails = (
+ starting_agent.input_guardrails + (run_config.input_guardrails or [])
+ if current_turn == 0 and not is_resumed_state
+ else []
+ )
+ sequential_guardrails = [g for g in all_input_guardrails if not g.run_in_parallel]
+ parallel_guardrails = [g for g in all_input_guardrails if g.run_in_parallel]
+ current_bindings = bind_public_agent(current_agent)
+ execution_agent = current_bindings.execution_agent
+ prepared_turn_input = copy_input_items(streamed_result.input)
+ if sandbox_runtime is not None and sandbox_runtime.enabled and sequential_guardrails:
+ # Mirror the non-streaming path: a blocking first-turn guardrail should fire
+ # before sandbox prep can create, start, or mutate sandbox state.
+ existing_input_guardrail_count = len(streamed_result.input_guardrail_results)
+ await run_input_guardrails_with_queue(
+ starting_agent,
+ sequential_guardrails,
+ ItemHelpers.input_to_new_input_list(prepared_turn_input),
+ context_wrapper,
+ streamed_result,
+ None,
+ )
+ for result in streamed_result.input_guardrail_results[
+ existing_input_guardrail_count:
+ ]:
+ if result.output.tripwire_triggered:
+ streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
+ session_input_items_for_persistence = (
+ await persist_session_items_for_guardrail_trip(
+ session,
+ server_conversation_tracker,
+ session_input_items_for_persistence,
+ starting_input,
+ run_state,
+ store=current_agent.model_settings.resolve(
+ run_config.model_settings
+ ).store,
+ )
+ )
+ raise InputGuardrailTripwireTriggered(result)
+ sequential_guardrails = []
+
+ if sandbox_runtime is not None:
+ prepared_sandbox = await sandbox_runtime.prepare_agent(
+ current_agent=current_agent,
+ current_input=prepared_turn_input,
+ context_wrapper=context_wrapper,
+ is_resumed_state=is_resumed_state,
+ )
+ current_bindings = prepared_sandbox.bindings
+ execution_agent = current_bindings.execution_agent
+ prepared_turn_input = copy_input_items(prepared_sandbox.input)
+ streamed_result.input = prepared_turn_input
+ streamed_result._original_input = copy_input_items(prepared_turn_input)
+ if run_state is not None:
+ run_state._original_input = copy_input_items(prepared_turn_input)
+ sandbox_runtime.apply_result_metadata(streamed_result)
+
if is_resumed_state and run_state is not None and run_state._current_step is not None:
if isinstance(run_state._current_step, NextStepInterruption):
if not run_state._model_responses or not run_state._last_processed_response:
@@ -623,7 +715,7 @@ async def start_streaming(
last_model_response = run_state._model_responses[-1]
turn_result = await resolve_interrupted_turn(
- agent=current_agent,
+ bindings=current_bindings,
original_input=run_state._original_input,
original_pre_step_items=run_state._generated_items,
new_response=last_model_response,
@@ -638,7 +730,12 @@ async def start_streaming(
current_agent, run_state._last_processed_response
)
streamed_result._tool_use_tracker_snapshot = serialize_tool_use_tracker(
- tool_use_tracker
+ tool_use_tracker,
+ starting_agent=(
+ run_state._starting_agent
+ if run_state is not None and run_state._starting_agent is not None
+ else starting_agent
+ ),
)
streamed_result.input = turn_result.original_input
@@ -729,14 +826,14 @@ async def start_streaming(
if streamed_result.is_complete:
break
- all_tools = await get_all_tools(current_agent, context_wrapper)
+ all_tools = await get_all_tools(execution_agent, context_wrapper)
await initialize_computer_tools(tools=all_tools, context_wrapper=context_wrapper)
if current_span is None:
handoff_names = [
- h.agent_name for h in await get_handoffs(current_agent, context_wrapper)
+ h.agent_name for h in await get_handoffs(execution_agent, context_wrapper)
]
- if output_schema := get_output_schema(current_agent):
+ if output_schema := get_output_schema(execution_agent):
output_type_name = output_schema.name()
else:
output_type_name = "str"
@@ -838,17 +935,11 @@ async def start_streaming(
break
if current_turn == 1:
- all_input_guardrails = starting_agent.input_guardrails + (
- run_config.input_guardrails or []
- )
- sequential_guardrails = [g for g in all_input_guardrails if not g.run_in_parallel]
- parallel_guardrails = [g for g in all_input_guardrails if g.run_in_parallel]
-
if sequential_guardrails:
await run_input_guardrails_with_queue(
starting_agent,
sequential_guardrails,
- ItemHelpers.input_to_new_input_list(prepared_input),
+ ItemHelpers.input_to_new_input_list(prepared_turn_input),
context_wrapper,
streamed_result,
current_span,
@@ -875,7 +966,7 @@ async def start_streaming(
run_input_guardrails_with_queue(
starting_agent,
parallel_guardrails,
- ItemHelpers.input_to_new_input_list(prepared_input),
+ ItemHelpers.input_to_new_input_list(prepared_turn_input),
context_wrapper,
streamed_result,
current_span,
@@ -887,35 +978,49 @@ async def start_streaming(
current_turn,
current_agent.name,
)
- if (
- session is not None
- and server_conversation_tracker is None
- and not streamed_result._stream_input_persisted
- ):
- streamed_result._original_input_for_persistence = (
- session_input_items_for_persistence
- if session_input_items_for_persistence is not None
- else []
- )
- turn_result = await run_single_turn_streamed(
- streamed_result,
- current_agent,
- hooks,
- context_wrapper,
- run_config,
- should_run_agent_start_hooks,
- tool_use_tracker,
- all_tools,
- server_conversation_tracker,
- pending_server_items=pending_server_items,
- session=session,
- session_items_to_rewind=(
- streamed_result._original_input_for_persistence
- if session is not None and server_conversation_tracker is None
- else None
- ),
- reasoning_item_id_policy=resolved_reasoning_item_id_policy,
+ turn_usage_start = snapshot_usage(context_wrapper.usage)
+ current_turn_span = turn_span(
+ turn=current_turn,
+ agent_name=current_agent.name,
)
+ current_turn_span.start(mark_as_current=True)
+ try:
+ if (
+ session is not None
+ and server_conversation_tracker is None
+ and not streamed_result._stream_input_persisted
+ ):
+ streamed_result._original_input_for_persistence = (
+ session_input_items_for_persistence
+ if session_input_items_for_persistence is not None
+ else []
+ )
+ turn_result = await run_single_turn_streamed(
+ streamed_result,
+ current_bindings,
+ hooks,
+ context_wrapper,
+ run_config,
+ should_run_agent_start_hooks,
+ tool_use_tracker,
+ all_tools,
+ server_conversation_tracker,
+ pending_server_items=pending_server_items,
+ session=session,
+ session_items_to_rewind=(
+ streamed_result._original_input_for_persistence
+ if session is not None and server_conversation_tracker is None
+ else None
+ ),
+ reasoning_item_id_policy=resolved_reasoning_item_id_policy,
+ prompt_cache_key_resolver=prompt_cache_key_resolver,
+ )
+ finally:
+ attach_usage_to_span(
+ current_turn_span,
+ usage_delta(turn_usage_start, context_wrapper.usage),
+ )
+ current_turn_span.finish(reset_current=True)
logger.debug(
"Turn %s complete, next_step type=%s",
current_turn,
@@ -923,7 +1028,12 @@ async def start_streaming(
)
should_run_agent_start_hooks = False
streamed_result._tool_use_tracker_snapshot = serialize_tool_use_tracker(
- tool_use_tracker
+ tool_use_tracker,
+ starting_agent=(
+ run_state._starting_agent
+ if run_state is not None and run_state._starting_agent is not None
+ else starting_agent
+ ),
)
streamed_result.raw_responses = streamed_result.raw_responses + [
@@ -1093,6 +1203,12 @@ async def start_streaming(
logger.warning("Failed to dispose computers after streamed run: %s", error)
if current_span:
current_span.finish(reset_current=True)
+ if current_task_span:
+ attach_usage_to_span(
+ current_task_span,
+ usage_delta(task_usage_start, context_wrapper.usage),
+ )
+ current_task_span.finish(reset_current=True)
if streamed_result.trace:
streamed_result.trace.finish(reset_current=True)
@@ -1103,7 +1219,7 @@ async def start_streaming(
async def run_single_turn_streamed(
streamed_result: RunResultStreaming,
- agent: Agent[TContext],
+ bindings: AgentBindings[TContext],
hooks: RunHooks[TContext],
context_wrapper: RunContextWrapper[TContext],
run_config: RunConfig,
@@ -1115,8 +1231,11 @@ async def run_single_turn_streamed(
session_items_to_rewind: list[TResponseInputItem] | None = None,
pending_server_items: list[RunItem] | None = None,
reasoning_item_id_policy: ReasoningItemIdPolicy | None = None,
+ prompt_cache_key_resolver: PromptCacheKeyResolver | None = None,
) -> SingleStepResult:
"""Run a single streamed turn and emit events as results arrive."""
+ public_agent = bindings.public_agent
+ execution_agent = bindings.execution_agent
emitted_tool_call_ids: set[str] = set()
emitted_reasoning_item_ids: set[str] = set()
emitted_tool_search_fingerprints: set[str] = set()
@@ -1162,28 +1281,28 @@ async def run_single_turn_streamed(
turn_input=turn_input,
)
await asyncio.gather(
- hooks.on_agent_start(agent_hook_context, agent),
+ hooks.on_agent_start(agent_hook_context, public_agent),
(
- agent.hooks.on_start(agent_hook_context, agent)
- if agent.hooks
+ public_agent.hooks.on_start(agent_hook_context, public_agent)
+ if public_agent.hooks
else _coro.noop_coroutine()
),
)
- output_schema = get_output_schema(agent)
+ output_schema = get_output_schema(execution_agent)
- streamed_result.current_agent = agent
- streamed_result._current_agent_output_schema = output_schema
+ streamed_result.current_agent = public_agent
+ streamed_result._current_agent_output_schema = get_output_schema(public_agent)
system_prompt, prompt_config = await asyncio.gather(
- agent.get_system_prompt(context_wrapper),
- agent.get_prompt(context_wrapper),
+ execution_agent.get_system_prompt(context_wrapper),
+ execution_agent.get_prompt(context_wrapper),
)
- handoffs = await get_handoffs(agent, context_wrapper)
- model = get_model(agent, run_config)
- model_settings = agent.model_settings.resolve(run_config.model_settings)
- model_settings = maybe_reset_tool_choice(agent, tool_use_tracker, model_settings)
+ handoffs = await get_handoffs(execution_agent, context_wrapper)
+ model = get_model(execution_agent, run_config)
+ model_settings = execution_agent.model_settings.resolve(run_config.model_settings)
+ model_settings = maybe_reset_tool_choice(public_agent, tool_use_tracker, model_settings)
final_response: ModelResponse | None = None
@@ -1207,7 +1326,7 @@ async def run_single_turn_streamed(
)
filtered = await maybe_filter_model_input(
- agent=agent,
+ agent=public_agent,
run_config=run_config,
context_wrapper=context_wrapper,
input_items=input,
@@ -1231,10 +1350,15 @@ async def run_single_turn_streamed(
raise RuntimeError("Prepared model input is empty")
await asyncio.gather(
- hooks.on_llm_start(context_wrapper, agent, filtered.instructions, filtered.input),
+ hooks.on_llm_start(context_wrapper, public_agent, filtered.instructions, filtered.input),
(
- agent.hooks.on_llm_start(context_wrapper, agent, filtered.instructions, filtered.input)
- if agent.hooks
+ public_agent.hooks.on_llm_start(
+ context_wrapper,
+ public_agent,
+ filtered.instructions,
+ filtered.input,
+ )
+ if public_agent.hooks
else _coro.noop_coroutine()
),
)
@@ -1243,7 +1367,7 @@ async def run_single_turn_streamed(
not streamed_result._stream_input_persisted
and session is not None
and server_conversation_tracker is None
- and streamed_result._original_input_for_persistence
+ and streamed_result._original_input_for_persistence is not None
and len(streamed_result._original_input_for_persistence) > 0
):
streamed_result._stream_input_persisted = True
@@ -1270,6 +1394,19 @@ async def run_single_turn_streamed(
else:
logger.debug("No conversation_id available for request")
+ prompt_cache_key = (
+ prompt_cache_key_resolver.resolve(
+ model_settings,
+ model=model,
+ conversation_id=conversation_id,
+ session=session,
+ group_id=run_config.group_id,
+ )
+ if prompt_cache_key_resolver is not None
+ else None
+ )
+ model_settings = model_settings_with_prompt_cache_key(model_settings, prompt_cache_key)
+
async def rewind_model_request() -> None:
items_to_rewind = session_items_to_rewind if session_items_to_rewind is not None else []
await rewind_session_items(session, items_to_rewind, server_conversation_tracker)
@@ -1277,6 +1414,7 @@ async def run_single_turn_streamed(
server_conversation_tracker.rewind_input(filtered.input)
stream_failed_retry_attempts: list[int] = [0]
+
retry_stream = stream_response_with_retry(
get_stream=lambda: model.stream_response(
filtered.instructions,
@@ -1344,7 +1482,7 @@ async def run_single_turn_streamed(
RunItemStreamEvent(
item=ToolSearchCallItem(
raw_item=coerce_tool_search_call_raw_item(output_item),
- agent=agent,
+ agent=public_agent,
),
name="tool_search_called",
)
@@ -1356,7 +1494,7 @@ async def run_single_turn_streamed(
RunItemStreamEvent(
item=ToolSearchOutputItem(
raw_item=coerce_tool_search_output_raw_item(output_item),
- agent=agent,
+ agent=public_agent,
),
name="tool_search_output_created",
)
@@ -1398,7 +1536,7 @@ async def run_single_turn_streamed(
tool_item = ToolCallItem(
raw_item=cast(ToolCallItemTypes, output_item),
- agent=agent,
+ agent=public_agent,
description=tool_description,
title=tool_title,
)
@@ -1412,7 +1550,7 @@ async def run_single_turn_streamed(
if reasoning_id and reasoning_id not in emitted_reasoning_item_ids:
emitted_reasoning_item_ids.add(reasoning_id)
- reasoning_item = ReasoningItem(raw_item=output_item, agent=agent)
+ reasoning_item = ReasoningItem(raw_item=output_item, agent=public_agent)
streamed_result._event_queue.put_nowait(
RunItemStreamEvent(item=reasoning_item, name="reasoning_item_created")
)
@@ -1421,11 +1559,11 @@ async def run_single_turn_streamed(
context_wrapper.usage.add(final_response.usage)
await asyncio.gather(
(
- agent.hooks.on_llm_end(context_wrapper, agent, final_response)
- if agent.hooks
+ public_agent.hooks.on_llm_end(context_wrapper, public_agent, final_response)
+ if public_agent.hooks
else _coro.noop_coroutine()
),
- hooks.on_llm_end(context_wrapper, agent, final_response),
+ hooks.on_llm_end(context_wrapper, public_agent, final_response),
)
if not final_response:
@@ -1438,7 +1576,7 @@ async def run_single_turn_streamed(
server_conversation_tracker.track_server_items(final_response)
single_step_result = await get_single_step_result_from_response(
- agent=agent,
+ bindings=bindings,
original_input=streamed_result.input,
pre_step_items=streamed_result._model_input_items,
new_response=final_response,
@@ -1483,7 +1621,7 @@ async def run_single_turn_streamed(
item
for item in items_to_filter
if not (
- isinstance(item, (ToolSearchCallItem, ToolSearchOutputItem))
+ isinstance(item, ToolSearchCallItem | ToolSearchOutputItem)
and _tool_search_fingerprint(item.raw_item) in emitted_tool_search_fingerprints
)
]
@@ -1497,7 +1635,7 @@ async def run_single_turn_streamed(
async def run_single_turn(
*,
- agent: Agent[TContext],
+ bindings: AgentBindings[TContext],
all_tools: list[Tool],
original_input: str | list[TResponseInputItem],
generated_items: list[RunItem],
@@ -1510,8 +1648,11 @@ async def run_single_turn(
session: Session | None = None,
session_items_to_rewind: list[TResponseInputItem] | None = None,
reasoning_item_id_policy: ReasoningItemIdPolicy | None = None,
+ prompt_cache_key_resolver: PromptCacheKeyResolver | None = None,
) -> SingleStepResult:
"""Run a single non-streaming turn of the agent loop."""
+ public_agent = bindings.public_agent
+ execution_agent = bindings.execution_agent
try:
turn_input = ItemHelpers.input_to_new_input_list(original_input)
except Exception:
@@ -1526,28 +1667,28 @@ async def run_single_turn(
turn_input=turn_input,
)
await asyncio.gather(
- hooks.on_agent_start(agent_hook_context, agent),
+ hooks.on_agent_start(agent_hook_context, public_agent),
(
- agent.hooks.on_start(agent_hook_context, agent)
- if agent.hooks
+ public_agent.hooks.on_start(agent_hook_context, public_agent)
+ if public_agent.hooks
else _coro.noop_coroutine()
),
)
system_prompt, prompt_config = await asyncio.gather(
- agent.get_system_prompt(context_wrapper),
- agent.get_prompt(context_wrapper),
+ execution_agent.get_system_prompt(context_wrapper),
+ execution_agent.get_prompt(context_wrapper),
)
- output_schema = get_output_schema(agent)
- handoffs = await get_handoffs(agent, context_wrapper)
+ output_schema = get_output_schema(execution_agent)
+ handoffs = await get_handoffs(execution_agent, context_wrapper)
if server_conversation_tracker is not None:
input = server_conversation_tracker.prepare_input(original_input, generated_items)
else:
input = _prepare_turn_input_items(original_input, generated_items, reasoning_item_id_policy)
new_response = await get_new_response(
- agent,
+ bindings,
system_prompt,
input,
output_schema,
@@ -1561,10 +1702,11 @@ async def run_single_turn(
prompt_config,
session=session,
session_items_to_rewind=session_items_to_rewind,
+ prompt_cache_key_resolver=prompt_cache_key_resolver,
)
return await get_single_step_result_from_response(
- agent=agent,
+ bindings=bindings,
original_input=original_input,
pre_step_items=generated_items,
new_response=new_response,
@@ -1579,7 +1721,7 @@ async def run_single_turn(
async def get_new_response(
- agent: Agent[TContext],
+ bindings: AgentBindings[TContext],
system_prompt: str | None,
input: list[TResponseInputItem],
output_schema: AgentOutputSchemaBase | None,
@@ -1593,10 +1735,13 @@ async def get_new_response(
prompt_config: ResponsePromptParam | None,
session: Session | None = None,
session_items_to_rewind: list[TResponseInputItem] | None = None,
+ prompt_cache_key_resolver: PromptCacheKeyResolver | None = None,
) -> ModelResponse:
"""Call the model and return the raw response, handling retries and hooks."""
+ public_agent = bindings.public_agent
+ execution_agent = bindings.execution_agent
filtered = await maybe_filter_model_input(
- agent=agent,
+ agent=public_agent,
run_config=run_config,
context_wrapper=context_wrapper,
input_items=input,
@@ -1605,23 +1750,23 @@ async def get_new_response(
if isinstance(filtered.input, list):
filtered.input = deduplicate_input_items_preferring_latest(filtered.input)
- model = get_model(agent, run_config)
- model_settings = agent.model_settings.resolve(run_config.model_settings)
- model_settings = maybe_reset_tool_choice(agent, tool_use_tracker, model_settings)
+ model = get_model(execution_agent, run_config)
+ model_settings = execution_agent.model_settings.resolve(run_config.model_settings)
+ model_settings = maybe_reset_tool_choice(public_agent, tool_use_tracker, model_settings)
if server_conversation_tracker is not None:
server_conversation_tracker.mark_input_as_sent(filtered.input)
await asyncio.gather(
- hooks.on_llm_start(context_wrapper, agent, filtered.instructions, filtered.input),
+ hooks.on_llm_start(context_wrapper, public_agent, filtered.instructions, filtered.input),
(
- agent.hooks.on_llm_start(
+ public_agent.hooks.on_llm_start(
context_wrapper,
- agent,
+ public_agent,
filtered.instructions,
filtered.input,
)
- if agent.hooks
+ if public_agent.hooks
else _coro.noop_coroutine()
),
)
@@ -1640,6 +1785,19 @@ async def get_new_response(
else:
logger.debug("No conversation_id available for request")
+ prompt_cache_key = (
+ prompt_cache_key_resolver.resolve(
+ model_settings,
+ model=model,
+ conversation_id=conversation_id,
+ session=session,
+ group_id=run_config.group_id,
+ )
+ if prompt_cache_key_resolver is not None
+ else None
+ )
+ model_settings = model_settings_with_prompt_cache_key(model_settings, prompt_cache_key)
+
async def rewind_model_request() -> None:
items_to_rewind = session_items_to_rewind if session_items_to_rewind is not None else []
await rewind_session_items(session, items_to_rewind, server_conversation_tracker)
@@ -1677,11 +1835,11 @@ async def get_new_response(
await asyncio.gather(
(
- agent.hooks.on_llm_end(context_wrapper, agent, new_response)
- if agent.hooks
+ public_agent.hooks.on_llm_end(context_wrapper, public_agent, new_response)
+ if public_agent.hooks
else _coro.noop_coroutine()
),
- hooks.on_llm_end(context_wrapper, agent, new_response),
+ hooks.on_llm_end(context_wrapper, public_agent, new_response),
)
return new_response
diff --git a/src/agents/run_internal/run_steps.py b/src/agents/run_internal/run_steps.py
index 27744a21..2145d77e 100644
--- a/src/agents/run_internal/run_steps.py
+++ b/src/agents/run_internal/run_steps.py
@@ -19,6 +19,7 @@ from ..items import ModelResponse, RunItem, ToolApprovalItem, TResponseInputItem
from ..tool import (
ApplyPatchTool,
ComputerTool,
+ CustomTool,
FunctionTool,
HostedMCPTool,
LocalShellTool,
@@ -33,6 +34,7 @@ __all__ = [
"ToolRunHandoff",
"ToolRunFunction",
"ToolRunComputerAction",
+ "ToolRunCustom",
"ToolRunMCPApprovalRequest",
"ToolRunLocalShellCall",
"ToolRunShellCall",
@@ -73,6 +75,12 @@ class ToolRunComputerAction:
computer_tool: ComputerTool[Any]
+@dataclass
+class ToolRunCustom:
+ tool_call: Any
+ custom_tool: CustomTool
+
+
@dataclass
class ToolRunMCPApprovalRequest:
request_item: McpApprovalRequest
@@ -109,6 +117,7 @@ class ProcessedResponse:
tools_used: list[str] # Names of all tools used, including hosted tools
mcp_approval_requests: list[ToolRunMCPApprovalRequest] # Only requests with callbacks
interruptions: list[ToolApprovalItem] # Tool approval items awaiting user decision
+ custom_tool_calls: list[ToolRunCustom] = dataclasses.field(default_factory=list)
def has_tools_or_approvals_to_run(self) -> bool:
# Handoffs, functions and computer actions need local processing
@@ -118,6 +127,7 @@ class ProcessedResponse:
self.handoffs,
self.functions,
self.computer_actions,
+ self.custom_tool_calls,
self.local_shell_calls,
self.shell_calls,
self.apply_patch_calls,
diff --git a/src/agents/run_internal/session_persistence.py b/src/agents/run_internal/session_persistence.py
index 6f27dfd8..25874ad3 100644
--- a/src/agents/run_internal/session_persistence.py
+++ b/src/agents/run_internal/session_persistence.py
@@ -329,7 +329,7 @@ async def save_result_to_session(
if response_id and is_openai_responses_compaction_aware_session(session):
has_local_tool_outputs = any(
- isinstance(item, (ToolCallOutputItem, HandoffOutputItem)) for item in new_items
+ isinstance(item, ToolCallOutputItem | HandoffOutputItem) for item in new_items
)
if has_local_tool_outputs:
defer_compaction = getattr(session, "_defer_compaction", None)
diff --git a/src/agents/run_internal/tool_actions.py b/src/agents/run_internal/tool_actions.py
index 005a0b16..7efbaf49 100644
--- a/src/agents/run_internal/tool_actions.py
+++ b/src/agents/run_internal/tool_actions.py
@@ -26,17 +26,19 @@ from ..run_config import RunConfig
from ..run_context import RunContextWrapper
from ..tool import (
ApplyPatchTool,
+ CustomTool,
LocalShellCommandRequest,
ShellCommandRequest,
ShellResult,
resolve_computer,
)
+from ..tool_context import ToolContext
from ..tracing import SpanError
from ..util import _coro
from ..util._approvals import evaluate_needs_approval_setting
from .items import apply_patch_rejection_item, shell_rejection_item
from .tool_execution import (
- coerce_apply_patch_operation,
+ coerce_apply_patch_operations,
coerce_shell_call,
extract_apply_patch_call_id,
format_shell_error,
@@ -58,6 +60,7 @@ if TYPE_CHECKING:
from .run_steps import (
ToolRunApplyPatchCall,
ToolRunComputerAction,
+ ToolRunCustom,
ToolRunLocalShellCall,
ToolRunShellCall,
)
@@ -66,6 +69,7 @@ __all__ = [
"ComputerAction",
"LocalShellAction",
"ShellAction",
+ "CustomToolAction",
"ApplyPatchAction",
]
@@ -520,6 +524,139 @@ class ShellAction:
)
+class CustomToolAction:
+ """Execute Responses custom tool calls and return custom_tool_call_output items."""
+
+ @classmethod
+ async def execute(
+ cls,
+ *,
+ agent: Agent[Any],
+ call: ToolRunCustom,
+ hooks: RunHooks[Any],
+ context_wrapper: RunContextWrapper[Any],
+ config: RunConfig,
+ ) -> RunItem:
+ custom_tool: CustomTool = call.custom_tool
+ agent_hooks = agent.hooks
+ call_id = get_mapping_or_attr(call.tool_call, "call_id")
+ tool_input = get_mapping_or_attr(call.tool_call, "input")
+ if not isinstance(call_id, str):
+ raise ModelBehaviorError("Custom tool call is missing call_id.")
+ if not isinstance(tool_input, str):
+ raise ModelBehaviorError("Custom tool call is missing input.")
+
+ tool_context = ToolContext.from_agent_context(
+ context_wrapper,
+ call_id,
+ tool_name=custom_tool.name,
+ tool_arguments=tool_input,
+ agent=agent,
+ run_config=config,
+ )
+
+ async def _run_call(span: Any | None) -> RunItem:
+ if span and config.trace_include_sensitive_data:
+ span.span_data.input = tool_input
+
+ needs_approval_result = await evaluate_needs_approval_setting(
+ custom_tool.runtime_needs_approval(), context_wrapper, tool_input, call_id
+ )
+
+ if needs_approval_result:
+ approval_status, approval_item = await resolve_approval_status(
+ tool_name=custom_tool.name,
+ call_id=call_id,
+ raw_item=call.tool_call,
+ agent=agent,
+ context_wrapper=context_wrapper,
+ on_approval=custom_tool.runtime_on_approval(),
+ )
+
+ if approval_status is False:
+ rejection_message = await resolve_approval_rejection_message(
+ context_wrapper=context_wrapper,
+ run_config=config,
+ tool_type="custom",
+ tool_name=custom_tool.name,
+ call_id=call_id,
+ )
+ return cls._tool_output_item(agent, call_id, rejection_message)
+
+ if approval_status is not True:
+ return approval_item
+
+ await asyncio.gather(
+ hooks.on_tool_start(tool_context, agent, custom_tool),
+ (
+ agent_hooks.on_tool_start(tool_context, agent, custom_tool)
+ if agent_hooks
+ else _coro.noop_coroutine()
+ ),
+ )
+
+ try:
+ result = custom_tool.on_invoke_tool(tool_context, tool_input)
+ result = await result if inspect.isawaitable(result) else result
+ output_text = cls._normalize_output(result)
+ except Exception as exc:
+ output_text = format_shell_error(exc)
+ trace_error = get_trace_tool_error(
+ trace_include_sensitive_data=config.trace_include_sensitive_data,
+ error_message=output_text,
+ )
+ if span:
+ span.set_error(
+ SpanError(
+ message="Error running tool",
+ data={
+ "tool_name": custom_tool.name,
+ "error": trace_error,
+ },
+ )
+ )
+ logger.error("Custom tool failed: %s", exc, exc_info=True)
+
+ await asyncio.gather(
+ hooks.on_tool_end(tool_context, agent, custom_tool, output_text),
+ (
+ agent_hooks.on_tool_end(tool_context, agent, custom_tool, output_text)
+ if agent_hooks
+ else _coro.noop_coroutine()
+ ),
+ )
+
+ if span and config.trace_include_sensitive_data:
+ span.span_data.output = output_text
+
+ return cls._tool_output_item(agent, call_id, output_text)
+
+ return await with_tool_function_span(
+ config=config,
+ tool_name=custom_tool.name,
+ fn=_run_call,
+ )
+
+ @staticmethod
+ def _normalize_output(output: Any) -> str:
+ return output if isinstance(output, str) else str(output)
+
+ @staticmethod
+ def _tool_output_item(agent: Agent[Any], call_id: str, output: str) -> ToolCallOutputItem:
+ return ToolCallOutputItem(
+ agent=agent,
+ output=output,
+ raw_item=cast(
+ Any,
+ {
+ "type": "custom_tool_call_output",
+ "call_id": call_id,
+ "output": output,
+ },
+ ),
+ )
+
+
class ApplyPatchAction:
"""Execute apply_patch operations with approvals and editor integration."""
@@ -536,7 +673,7 @@ class ApplyPatchAction:
"""Run an apply_patch call and serialize the editor result for the model."""
apply_patch_tool: ApplyPatchTool = call.apply_patch_tool
agent_hooks = agent.hooks
- operation = coerce_apply_patch_operation(
+ operations = coerce_apply_patch_operations(
call.tool_call,
context_wrapper=context_wrapper,
)
@@ -545,16 +682,23 @@ class ApplyPatchAction:
async def _run_call(span: Any | None) -> RunItem:
if span and config.trace_include_sensitive_data:
span.span_data.input = _serialize_trace_payload(
- {
- "type": operation.type,
- "path": operation.path,
- "diff": operation.diff,
- }
+ [
+ {
+ "type": operation.type,
+ "path": operation.path,
+ "diff": operation.diff,
+ }
+ for operation in operations
+ ]
)
- needs_approval_result = await evaluate_needs_approval_setting(
- apply_patch_tool.needs_approval, context_wrapper, operation, call_id
- )
+ needs_approval_result = False
+ for operation in operations:
+ if await evaluate_needs_approval_setting(
+ apply_patch_tool.needs_approval, context_wrapper, operation, call_id
+ ):
+ needs_approval_result = True
+ break
if needs_approval_result:
approval_status, approval_item = await resolve_approval_status(
@@ -577,6 +721,7 @@ class ApplyPatchAction:
return apply_patch_rejection_item(
agent,
call_id,
+ output_type="apply_patch_call_output",
rejection_message=rejection_message,
)
@@ -596,23 +741,28 @@ class ApplyPatchAction:
output_text = ""
try:
+ operation_outputs: list[str] = []
editor = apply_patch_tool.editor
- if operation.type == "create_file":
- result = editor.create_file(operation)
- elif operation.type == "update_file":
- result = editor.update_file(operation)
- elif operation.type == "delete_file":
- result = editor.delete_file(operation)
- else: # pragma: no cover - validated in coerce_apply_patch_operation
- raise ModelBehaviorError(f"Unsupported apply_patch operation: {operation.type}")
+ for operation in operations:
+ if operation.type == "create_file":
+ result = editor.create_file(operation)
+ elif operation.type == "update_file":
+ result = editor.update_file(operation)
+ elif operation.type == "delete_file":
+ result = editor.delete_file(operation)
+ else: # pragma: no cover - validated in coerce_apply_patch_operations
+ raise ModelBehaviorError(
+ f"Unsupported apply_patch operation: {operation.type}"
+ )
- awaited = await result if inspect.isawaitable(result) else result
- normalized = normalize_apply_patch_result(awaited)
- if normalized:
- if normalized.status in {"completed", "failed"}:
- status = normalized.status
- if normalized.output:
- output_text = normalized.output
+ awaited = await result if inspect.isawaitable(result) else result
+ normalized = normalize_apply_patch_result(awaited)
+ if normalized:
+ if normalized.status in {"completed", "failed"}:
+ status = normalized.status
+ if normalized.output:
+ operation_outputs.append(normalized.output)
+ output_text = "\n".join(operation_outputs)
except Exception as exc:
status = "failed"
output_text = format_shell_error(exc)
@@ -669,5 +819,6 @@ __all__ = [
"ComputerAction",
"LocalShellAction",
"ShellAction",
+ "CustomToolAction",
"ApplyPatchAction",
]
diff --git a/src/agents/run_internal/tool_execution.py b/src/agents/run_internal/tool_execution.py
index f2a80702..ba9d2661 100644
--- a/src/agents/run_internal/tool_execution.py
+++ b/src/agents/run_internal/tool_execution.py
@@ -87,6 +87,7 @@ from ..util import _coro, _error_tracing
from ..util._approvals import evaluate_needs_approval_setting
from ..util._types import MaybeAwaitable
from ._asyncio_progress import get_function_tool_task_progress_deadline
+from .agent_bindings import AgentBindings, bind_public_agent
from .approvals import append_approval_error_output
from .items import (
REJECTION_MESSAGE,
@@ -102,6 +103,7 @@ if TYPE_CHECKING:
from .run_steps import (
ToolRunApplyPatchCall,
ToolRunComputerAction,
+ ToolRunCustom,
ToolRunFunction,
ToolRunLocalShellCall,
ToolRunShellCall,
@@ -116,6 +118,7 @@ __all__ = [
"parse_apply_patch_function_args",
"extract_apply_patch_call_id",
"coerce_apply_patch_operation",
+ "coerce_apply_patch_operations",
"normalize_apply_patch_result",
"is_apply_patch_name",
"normalize_shell_output",
@@ -139,6 +142,7 @@ __all__ = [
"function_needs_approval",
"resolve_enabled_function_tools",
"execute_function_tool_calls",
+ "execute_custom_tool_calls",
"execute_local_shell_calls",
"execute_shell_calls",
"execute_apply_patch_calls",
@@ -148,7 +152,8 @@ __all__ = [
REDACTED_TOOL_ERROR_MESSAGE = "Tool execution failed. Error details are redacted."
TToolSpanResult = TypeVar("TToolSpanResult")
-_FUNCTION_TOOL_CANCELLED_DRAIN_SECONDS = 0.1
+_FUNCTION_TOOL_CANCELLED_DRAIN_SECONDS = 0.25
+_FUNCTION_TOOL_CANCELLED_IMMEDIATE_STEP_LIMIT = 64
_FUNCTION_TOOL_POST_INVOKE_WAIT_SECONDS = 0.1
@@ -360,7 +365,7 @@ async def _wait_for_cancelled_function_tool_task_progress(
remaining_time: float,
*,
task_states: Mapping[asyncio.Task[Any], _FunctionToolTaskState],
-) -> bool:
+) -> tuple[bool, bool]:
"""Wait until a cancelled sibling can make another self-driven step."""
task_to_invoke_task = {
tracked_task: task_state.invoke_task
@@ -379,7 +384,7 @@ async def _wait_for_cancelled_function_tool_task_progress(
task: deadline for task, deadline in progress_deadlines.items() if deadline is not None
}
if not self_progressing_tasks:
- return False
+ return False, False
now = loop.time()
next_deadline = min(self_progressing_tasks.values())
@@ -390,9 +395,10 @@ async def _wait_for_cancelled_function_tool_task_progress(
timeout=min(delay, remaining_time),
return_when=asyncio.FIRST_COMPLETED,
)
- else:
- await asyncio.sleep(0)
- return True
+ return True, False
+
+ await asyncio.sleep(0)
+ return True, True
async def _wait_for_function_tool_task_completion(
@@ -468,19 +474,36 @@ async def _drain_cancelled_function_tool_tasks(
ignore_cancelled_tasks: set[asyncio.Task[Any]] | None = None,
) -> tuple[_FunctionToolFailure | None, set[asyncio.Task[Any]]]:
"""Drain cancelled siblings while they can continue making self-driven progress."""
+ remaining_immediate_steps = _FUNCTION_TOOL_CANCELLED_IMMEDIATE_STEP_LIMIT
+
+ async def _wait_for_progress(
+ remaining: set[asyncio.Task[Any]],
+ loop: asyncio.AbstractEventLoop,
+ remaining_time: float,
+ ) -> bool:
+ nonlocal remaining_immediate_steps
+ if remaining_immediate_steps <= 0:
+ return False
+
+ (
+ should_continue,
+ consumed_immediate_step,
+ ) = await _wait_for_cancelled_function_tool_task_progress(
+ remaining,
+ loop,
+ remaining_time,
+ task_states=task_states,
+ )
+ if consumed_immediate_step:
+ remaining_immediate_steps -= 1
+ return should_continue
+
return await _settle_pending_function_tool_tasks(
pending_tasks=pending_tasks,
task_states=task_states,
results_by_tool_run=results_by_tool_run,
timeout_seconds=_FUNCTION_TOOL_CANCELLED_DRAIN_SECONDS,
- wait_for_pending_tasks=lambda remaining, loop, remaining_time: (
- _wait_for_cancelled_function_tool_task_progress(
- remaining,
- loop,
- remaining_time,
- task_states=task_states,
- )
- ),
+ wait_for_pending_tasks=_wait_for_progress,
failure_sources_by_task=failure_sources_by_task,
ignore_cancelled_tasks=ignore_cancelled_tasks,
)
@@ -541,7 +564,7 @@ async def resolve_enabled_function_tools(
return []
enabled_results = await asyncio.gather(*(_check_tool_enabled(tool) for tool in function_tools))
- return [tool for tool, enabled in zip(function_tools, enabled_results) if enabled]
+ return [tool for tool, enabled in zip(function_tools, enabled_results, strict=False) if enabled]
async def initialize_computer_tools(
@@ -609,14 +632,12 @@ def coerce_shell_call(tool_call: Any) -> ShellCallData:
or get_mapping_or_attr(action_payload, "timeoutMs")
or get_mapping_or_attr(action_payload, "timeout")
)
- timeout_ms = int(timeout_value) if isinstance(timeout_value, (int, float)) else None
+ timeout_ms = int(timeout_value) if isinstance(timeout_value, int | float) else None
max_length_value = get_mapping_or_attr(action_payload, "max_output_length")
if max_length_value is None:
max_length_value = get_mapping_or_attr(action_payload, "maxOutputLength")
- max_output_length = (
- int(max_length_value) if isinstance(max_length_value, (int, float)) else None
- )
+ max_output_length = int(max_length_value) if isinstance(max_length_value, int | float) else None
action = ShellActionRequest(
commands=commands,
@@ -646,8 +667,11 @@ def _parse_apply_patch_json(payload: str, *, label: str) -> dict[str, Any]:
def parse_apply_patch_custom_input(input_json: str) -> dict[str, Any]:
- """Parse custom apply_patch tool input used when a tool passes raw JSON strings."""
- return _parse_apply_patch_json(input_json, label="input")
+ """Parse custom apply_patch tool input used by legacy hosted-tool rollouts."""
+ parsed = _parse_apply_patch_json(input_json, label="input")
+ if "operation" in parsed or "operations" in parsed:
+ return parsed
+ return {"operation": parsed}
def parse_apply_patch_function_args(arguments: str) -> dict[str, Any]:
@@ -666,8 +690,44 @@ def extract_apply_patch_call_id(tool_call: Any) -> str:
def coerce_apply_patch_operation(
tool_call: Any, *, context_wrapper: RunContextWrapper[Any]
) -> ApplyPatchOperation:
- """Normalize the tool payload into an ApplyPatchOperation the editor can consume."""
+ """Normalize a single-operation tool payload for legacy callers."""
+ operations = coerce_apply_patch_operations(tool_call, context_wrapper=context_wrapper)
+ if len(operations) != 1:
+ raise ModelBehaviorError(
+ f"Apply patch call includes {len(operations)} operations; expected exactly one."
+ )
+ return operations[0]
+
+
+def coerce_apply_patch_operations(
+ tool_call: Any,
+ *,
+ context_wrapper: RunContextWrapper[Any],
+) -> list[ApplyPatchOperation]:
+ """Normalize apply_patch payloads into one or more editor operations."""
+ raw_operations = get_mapping_or_attr(tool_call, "operations")
+ if isinstance(raw_operations, list):
+ operations = [
+ _coerce_apply_patch_operation_payload(operation, context_wrapper=context_wrapper)
+ for operation in raw_operations
+ ]
+ if not operations:
+ raise ModelBehaviorError("Apply patch call includes no operations.")
+ return operations
+
raw_operation = get_mapping_or_attr(tool_call, "operation")
+ if raw_operation is not None:
+ return [
+ _coerce_apply_patch_operation_payload(raw_operation, context_wrapper=context_wrapper)
+ ]
+
+ raise ModelBehaviorError("Apply patch call is missing an operation payload.")
+
+
+def _coerce_apply_patch_operation_payload(
+ raw_operation: Any, *, context_wrapper: RunContextWrapper[Any]
+) -> ApplyPatchOperation:
+ """Normalize the tool payload into an ApplyPatchOperation the editor can consume."""
if raw_operation is None:
raise ModelBehaviorError("Apply patch call is missing an operation payload.")
@@ -695,9 +755,19 @@ def coerce_apply_patch_operation(
path=str(path),
diff=diff,
ctx_wrapper=context_wrapper,
+ move_to=_coerce_apply_patch_move_to(raw_operation),
)
+def _coerce_apply_patch_move_to(raw_operation: Any) -> str | None:
+ move_to = get_mapping_or_attr(raw_operation, "move_to")
+ if move_to is None:
+ return None
+ if not isinstance(move_to, str) or not move_to:
+ raise ModelBehaviorError("Apply patch operation move_to must be a non-empty path.")
+ return move_to
+
+
def normalize_apply_patch_result(
result: ApplyPatchResult | Mapping[str, Any] | str | None,
) -> ApplyPatchResult | None:
@@ -1046,7 +1116,7 @@ async def resolve_approval_rejection_message(
*,
context_wrapper: RunContextWrapper[Any],
run_config: RunConfig,
- tool_type: Literal["function", "computer", "shell", "apply_patch"],
+ tool_type: Literal["function", "computer", "shell", "apply_patch", "custom"],
tool_name: str,
call_id: str,
tool_namespace: str | None = None,
@@ -1279,14 +1349,15 @@ class _FunctionToolBatchExecutor:
def __init__(
self,
*,
- agent: Agent[Any],
+ bindings: AgentBindings[Any],
tool_runs: list[ToolRunFunction],
hooks: RunHooks[Any],
context_wrapper: RunContextWrapper[Any],
config: RunConfig,
isolate_parallel_failures: bool | None,
) -> None:
- self.agent = agent
+ self.execution_agent = bindings.execution_agent
+ self.public_agent = bindings.public_agent
self.tool_runs = tool_runs
self.hooks = hooks
self.context_wrapper = context_wrapper
@@ -1310,7 +1381,7 @@ class _FunctionToolBatchExecutor:
list[FunctionToolResult], list[ToolInputGuardrailResult], list[ToolOutputGuardrailResult]
]:
self.available_function_tools = await resolve_enabled_function_tools(
- self.agent,
+ self.execution_agent,
self.context_wrapper,
)
for tool_run in self.tool_runs:
@@ -1464,10 +1535,10 @@ class _FunctionToolBatchExecutor:
tool_call.call_id,
tool_call=raw_tool_call,
tool_namespace=tool_context_namespace,
- agent=self.agent,
+ agent=self.public_agent,
run_config=self.config,
)
- agent_hooks = self.agent.hooks
+ agent_hooks = self.public_agent.hooks
if self.config.trace_include_sensitive_data:
span_fn.span_data.input = tool_call.arguments
@@ -1534,7 +1605,7 @@ class _FunctionToolBatchExecutor:
)
if approval_status is None:
approval_item = ToolApprovalItem(
- agent=self.agent,
+ agent=self.public_agent,
raw_item=raw_tool_call,
tool_name=func_tool.name,
tool_namespace=tool_namespace,
@@ -1574,7 +1645,7 @@ class _FunctionToolBatchExecutor:
tool=func_tool,
output=rejection_message,
run_item=function_rejection_item(
- self.agent,
+ self.public_agent,
tool_call,
rejection_message=rejection_message,
scope_id=self.tool_state_scope_id,
@@ -1594,16 +1665,16 @@ class _FunctionToolBatchExecutor:
rejected_message = await _execute_tool_input_guardrails(
func_tool=func_tool,
tool_context=tool_context,
- agent=self.agent,
+ agent=self.public_agent,
tool_input_guardrail_results=self.tool_input_guardrail_results,
)
if rejected_message is not None:
return rejected_message
await asyncio.gather(
- self.hooks.on_tool_start(tool_context, self.agent, func_tool),
+ self.hooks.on_tool_start(tool_context, self.public_agent, func_tool),
(
- agent_hooks.on_tool_start(tool_context, self.agent, func_tool)
+ agent_hooks.on_tool_start(tool_context, self.public_agent, func_tool)
if agent_hooks
else _coro.noop_coroutine()
),
@@ -1663,15 +1734,15 @@ class _FunctionToolBatchExecutor:
final_result = await _execute_tool_output_guardrails(
func_tool=func_tool,
tool_context=tool_context,
- agent=self.agent,
+ agent=self.public_agent,
real_result=real_result,
tool_output_guardrail_results=self.tool_output_guardrail_results,
)
await asyncio.gather(
- self.hooks.on_tool_end(tool_context, self.agent, func_tool, final_result),
+ self.hooks.on_tool_end(tool_context, self.public_agent, func_tool, final_result),
(
- agent_hooks.on_tool_end(tool_context, self.agent, func_tool, final_result)
+ agent_hooks.on_tool_end(tool_context, self.public_agent, func_tool, final_result)
if agent_hooks
else _coro.noop_coroutine()
),
@@ -1772,7 +1843,7 @@ class _FunctionToolBatchExecutor:
run_item = ToolCallOutputItem(
output=result,
raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, result),
- agent=self.agent,
+ agent=self.public_agent,
)
else:
# Skip tool output until nested interruptions are resolved.
@@ -1793,7 +1864,7 @@ class _FunctionToolBatchExecutor:
async def execute_function_tool_calls(
*,
- agent: Agent[Any],
+ bindings: AgentBindings[Any],
tool_runs: list[ToolRunFunction],
hooks: RunHooks[Any],
context_wrapper: RunContextWrapper[Any],
@@ -1804,7 +1875,7 @@ async def execute_function_tool_calls(
]:
"""Execute function tool calls with approvals, guardrails, and hooks."""
return await _FunctionToolBatchExecutor(
- agent=agent,
+ bindings=bindings,
tool_runs=tool_runs,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -1813,9 +1884,34 @@ async def execute_function_tool_calls(
).execute()
+async def execute_custom_tool_calls(
+ *,
+ public_agent: Agent[Any],
+ calls: list[ToolRunCustom],
+ context_wrapper: RunContextWrapper[Any],
+ hooks: RunHooks[Any],
+ config: RunConfig,
+) -> list[RunItem]:
+ """Run Responses custom tool calls serially and wrap outputs."""
+ from .tool_actions import CustomToolAction
+
+ results: list[RunItem] = []
+ for call in calls:
+ results.append(
+ await CustomToolAction.execute(
+ agent=public_agent,
+ call=call,
+ hooks=hooks,
+ context_wrapper=context_wrapper,
+ config=config,
+ )
+ )
+ return results
+
+
async def execute_local_shell_calls(
*,
- agent: Agent[Any],
+ public_agent: Agent[Any],
calls: list[ToolRunLocalShellCall],
context_wrapper: RunContextWrapper[Any],
hooks: RunHooks[Any],
@@ -1828,7 +1924,7 @@ async def execute_local_shell_calls(
for call in calls:
results.append(
await LocalShellAction.execute(
- agent=agent,
+ agent=public_agent,
call=call,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -1840,7 +1936,7 @@ async def execute_local_shell_calls(
async def execute_shell_calls(
*,
- agent: Agent[Any],
+ public_agent: Agent[Any],
calls: list[ToolRunShellCall],
context_wrapper: RunContextWrapper[Any],
hooks: RunHooks[Any],
@@ -1853,7 +1949,7 @@ async def execute_shell_calls(
for call in calls:
results.append(
await ShellAction.execute(
- agent=agent,
+ agent=public_agent,
call=call,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -1865,7 +1961,7 @@ async def execute_shell_calls(
async def execute_apply_patch_calls(
*,
- agent: Agent[Any],
+ public_agent: Agent[Any],
calls: list[ToolRunApplyPatchCall],
context_wrapper: RunContextWrapper[Any],
hooks: RunHooks[Any],
@@ -1878,7 +1974,7 @@ async def execute_apply_patch_calls(
for call in calls:
results.append(
await ApplyPatchAction.execute(
- agent=agent,
+ agent=public_agent,
call=call,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -1890,7 +1986,7 @@ async def execute_apply_patch_calls(
async def execute_computer_actions(
*,
- agent: Agent[Any],
+ public_agent: Agent[Any],
actions: list[ToolRunComputerAction],
hooks: RunHooks[Any],
context_wrapper: RunContextWrapper[Any],
@@ -1907,7 +2003,7 @@ async def execute_computer_actions(
for check in action.tool_call.pending_safety_checks:
data = ComputerToolSafetyCheckData(
ctx_wrapper=context_wrapper,
- agent=agent,
+ agent=public_agent,
tool_call=action.tool_call,
safety_check=check,
)
@@ -1926,7 +2022,7 @@ async def execute_computer_actions(
results.append(
await ComputerAction.execute(
- agent=agent,
+ agent=public_agent,
action=action,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -2090,7 +2186,7 @@ async def execute_approved_tools(
if tool_runs:
function_results, _, _ = await execute_function_tool_calls(
- agent=agent,
+ bindings=bind_public_agent(agent),
tool_runs=tool_runs,
hooks=hooks,
context_wrapper=context_wrapper,
diff --git a/src/agents/run_internal/tool_planning.py b/src/agents/run_internal/tool_planning.py
index dabb83b4..b08e5bf8 100644
--- a/src/agents/run_internal/tool_planning.py
+++ b/src/agents/run_internal/tool_planning.py
@@ -24,9 +24,11 @@ from ..items import (
from ..run_context import RunContextWrapper
from ..tool import FunctionTool, MCPToolApprovalRequest
from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult
+from .agent_bindings import AgentBindings
from .run_steps import (
ToolRunApplyPatchCall,
ToolRunComputerAction,
+ ToolRunCustom,
ToolRunFunction,
ToolRunLocalShellCall,
ToolRunMCPApprovalRequest,
@@ -36,6 +38,7 @@ from .tool_execution import (
collect_manual_mcp_approvals,
execute_apply_patch_calls,
execute_computer_actions,
+ execute_custom_tool_calls,
execute_function_tool_calls,
execute_local_shell_calls,
execute_shell_calls,
@@ -67,7 +70,7 @@ def _hashable_identity_value(value: Any) -> Hashable | None:
"""Convert a tool call field into a stable, hashable representation."""
if value is None:
return None
- if isinstance(value, (dict, list, tuple)):
+ if isinstance(value, dict | list | tuple):
try:
return json.dumps(value, sort_keys=True, default=str)
except Exception:
@@ -82,10 +85,14 @@ def _tool_call_identity(raw: Any) -> tuple[str | None, str | None, Hashable | No
call_id = getattr(raw, "call_id", None) or getattr(raw, "id", None)
name = getattr(raw, "name", None)
args = getattr(raw, "arguments", None)
+ if args is None:
+ args = getattr(raw, "input", None)
if isinstance(raw, dict):
call_id = raw.get("call_id") or raw.get("id") or call_id
name = raw.get("name", name)
args = raw.get("arguments", args)
+ if args is None:
+ args = raw.get("input")
return call_id, name, _hashable_identity_value(args)
@@ -173,6 +180,7 @@ class ToolExecutionPlan:
function_runs: list[ToolRunFunction] = _dc.field(default_factory=list)
computer_actions: list[ToolRunComputerAction] = _dc.field(default_factory=list)
+ custom_tool_calls: list[ToolRunCustom] = _dc.field(default_factory=list)
shell_calls: list[ToolRunShellCall] = _dc.field(default_factory=list)
apply_patch_calls: list[ToolRunApplyPatchCall] = _dc.field(default_factory=list)
local_shell_calls: list[ToolRunLocalShellCall] = _dc.field(default_factory=list)
@@ -245,6 +253,7 @@ def _build_plan_for_fresh_turn(
return ToolExecutionPlan(
function_runs=processed_response.functions,
computer_actions=processed_response.computer_actions,
+ custom_tool_calls=processed_response.custom_tool_calls,
shell_calls=processed_response.shell_calls,
apply_patch_calls=processed_response.apply_patch_calls,
local_shell_calls=processed_response.local_shell_calls,
@@ -265,6 +274,7 @@ def _build_plan_for_resume_turn(
function_runs: list[ToolRunFunction],
computer_actions: list[ToolRunComputerAction],
shell_calls: list[ToolRunShellCall],
+ custom_tool_calls: list[ToolRunCustom],
apply_patch_calls: list[ToolRunApplyPatchCall],
) -> ToolExecutionPlan:
"""Build a ToolExecutionPlan for a resumed turn."""
@@ -279,6 +289,7 @@ def _build_plan_for_resume_turn(
return ToolExecutionPlan(
function_runs=function_runs,
computer_actions=computer_actions,
+ custom_tool_calls=custom_tool_calls,
shell_calls=shell_calls,
apply_patch_calls=apply_patch_calls,
local_shell_calls=[],
@@ -291,6 +302,7 @@ def _build_plan_for_resume_turn(
def _collect_tool_interruptions(
*,
function_results: Sequence[Any],
+ custom_tool_results: Sequence[RunItem],
shell_results: Sequence[RunItem],
apply_patch_results: Sequence[RunItem],
) -> list[ToolApprovalItem]:
@@ -307,6 +319,9 @@ def _collect_tool_interruptions(
nested_interruptions = result.agent_run_result.interruptions
if nested_interruptions:
interruptions.extend(nested_interruptions)
+ for custom_tool_result in custom_tool_results:
+ if isinstance(custom_tool_result, ToolApprovalItem):
+ interruptions.append(custom_tool_result)
for shell_result in shell_results:
if isinstance(shell_result, ToolApprovalItem):
interruptions.append(shell_result)
@@ -320,6 +335,7 @@ def _build_tool_result_items(
*,
function_results: Sequence[Any],
computer_results: Sequence[RunItem],
+ custom_tool_results: Sequence[RunItem],
shell_results: Sequence[RunItem],
apply_patch_results: Sequence[RunItem],
local_shell_results: Sequence[RunItem] | None = None,
@@ -331,6 +347,7 @@ def _build_tool_result_items(
if isinstance(run_item, RunItemBase):
results.append(cast(RunItem, run_item))
results.extend(computer_results)
+ results.extend(custom_tool_results)
results.extend(shell_results)
results.extend(apply_patch_results)
if local_shell_results:
@@ -518,7 +535,7 @@ async def _select_function_tool_runs_for_resume(
async def _execute_tool_plan(
*,
plan: ToolExecutionPlan,
- agent: Agent[Any],
+ bindings: AgentBindings[Any],
hooks,
context_wrapper: RunContextWrapper[Any],
run_config,
@@ -531,12 +548,15 @@ async def _execute_tool_plan(
list[RunItem],
list[RunItem],
list[RunItem],
+ list[RunItem],
]:
"""Execute tool runs captured in a ToolExecutionPlan."""
+ public_agent = bindings.public_agent
isolate_function_tool_failures = len(plan.function_runs) > 1 or (
parallel
and (
bool(plan.computer_actions)
+ or bool(plan.custom_tool_calls)
or bool(plan.shell_calls)
or bool(plan.apply_patch_calls)
or bool(plan.local_shell_calls)
@@ -546,12 +566,13 @@ async def _execute_tool_plan(
(
(function_results, tool_input_guardrail_results, tool_output_guardrail_results),
computer_results,
+ custom_tool_results,
shell_results,
apply_patch_results,
local_shell_results,
) = await asyncio.gather(
execute_function_tool_calls(
- agent=agent,
+ bindings=bindings,
tool_runs=plan.function_runs,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -559,28 +580,35 @@ async def _execute_tool_plan(
isolate_parallel_failures=isolate_function_tool_failures,
),
execute_computer_actions(
- agent=agent,
+ public_agent=public_agent,
actions=plan.computer_actions,
hooks=hooks,
context_wrapper=context_wrapper,
config=run_config,
),
+ execute_custom_tool_calls(
+ public_agent=public_agent,
+ calls=plan.custom_tool_calls,
+ hooks=hooks,
+ context_wrapper=context_wrapper,
+ config=run_config,
+ ),
execute_shell_calls(
- agent=agent,
+ public_agent=public_agent,
calls=plan.shell_calls,
hooks=hooks,
context_wrapper=context_wrapper,
config=run_config,
),
execute_apply_patch_calls(
- agent=agent,
+ public_agent=public_agent,
calls=plan.apply_patch_calls,
hooks=hooks,
context_wrapper=context_wrapper,
config=run_config,
),
execute_local_shell_calls(
- agent=agent,
+ public_agent=public_agent,
calls=plan.local_shell_calls,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -593,7 +621,7 @@ async def _execute_tool_plan(
tool_input_guardrail_results,
tool_output_guardrail_results,
) = await execute_function_tool_calls(
- agent=agent,
+ bindings=bindings,
tool_runs=plan.function_runs,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -601,28 +629,35 @@ async def _execute_tool_plan(
isolate_parallel_failures=isolate_function_tool_failures,
)
computer_results = await execute_computer_actions(
- agent=agent,
+ public_agent=public_agent,
actions=plan.computer_actions,
hooks=hooks,
context_wrapper=context_wrapper,
config=run_config,
)
+ custom_tool_results = await execute_custom_tool_calls(
+ public_agent=public_agent,
+ calls=plan.custom_tool_calls,
+ hooks=hooks,
+ context_wrapper=context_wrapper,
+ config=run_config,
+ )
shell_results = await execute_shell_calls(
- agent=agent,
+ public_agent=public_agent,
calls=plan.shell_calls,
hooks=hooks,
context_wrapper=context_wrapper,
config=run_config,
)
apply_patch_results = await execute_apply_patch_calls(
- agent=agent,
+ public_agent=public_agent,
calls=plan.apply_patch_calls,
hooks=hooks,
context_wrapper=context_wrapper,
config=run_config,
)
local_shell_results = await execute_local_shell_calls(
- agent=agent,
+ public_agent=public_agent,
calls=plan.local_shell_calls,
hooks=hooks,
context_wrapper=context_wrapper,
@@ -634,6 +669,7 @@ async def _execute_tool_plan(
tool_input_guardrail_results,
tool_output_guardrail_results,
computer_results,
+ custom_tool_results,
shell_results,
apply_patch_results,
local_shell_results,
diff --git a/src/agents/run_internal/tool_use_tracker.py b/src/agents/run_internal/tool_use_tracker.py
index e763f175..60ff9a17 100644
--- a/src/agents/run_internal/tool_use_tracker.py
+++ b/src/agents/run_internal/tool_use_tracker.py
@@ -17,7 +17,11 @@ from ..items import (
ToolSearchCallItem,
ToolSearchOutputItem,
)
-from ..run_state import _build_agent_map
+from ..run_state import (
+ _build_agent_identity_keys_by_id,
+ _build_agent_identity_map,
+ _build_agent_map,
+)
from .run_steps import ProcessedResponse, ToolRunFunction
__all__ = [
@@ -112,11 +116,23 @@ class AgentToolUseTracker:
return tracker
-def serialize_tool_use_tracker(tool_use_tracker: AgentToolUseTracker) -> dict[str, list[str]]:
+def serialize_tool_use_tracker(
+ tool_use_tracker: AgentToolUseTracker,
+ *,
+ starting_agent: Agent[Any] | None = None,
+) -> dict[str, list[str]]:
"""Convert the AgentToolUseTracker into a serializable snapshot."""
+ agent_identity_keys_by_id = (
+ _build_agent_identity_keys_by_id(starting_agent) if starting_agent is not None else None
+ )
snapshot: dict[str, list[str]] = {}
for agent, tool_names in tool_use_tracker.agent_to_tools:
- snapshot[agent.name] = list(tool_names)
+ agent_key = None
+ if agent_identity_keys_by_id is not None:
+ agent_key = agent_identity_keys_by_id.get(id(agent))
+ if agent_key is None:
+ agent_key = getattr(agent, "name", agent.__class__.__name__)
+ snapshot.setdefault(agent_key, []).extend(tool_names)
return snapshot
@@ -131,8 +147,9 @@ def hydrate_tool_use_tracker(
return
agent_map = _build_agent_map(starting_agent)
+ agent_identity_map = _build_agent_identity_map(starting_agent)
for agent_name, tool_names in snapshot.items():
- agent = agent_map.get(agent_name)
+ agent = agent_identity_map.get(agent_name) or agent_map.get(agent_name)
if agent is None:
continue
tool_use_tracker.add_tool_use(agent, list(tool_names))
diff --git a/src/agents/run_internal/turn_preparation.py b/src/agents/run_internal/turn_preparation.py
index 1b44d54a..60d5d8f4 100644
--- a/src/agents/run_internal/turn_preparation.py
+++ b/src/agents/run_internal/turn_preparation.py
@@ -101,7 +101,7 @@ async def get_handoffs(agent: Agent[Any], context_wrapper: RunContextWrapper[Any
return bool(res)
results = await asyncio.gather(*(check_handoff_enabled(h) for h in handoffs))
- enabled: list[Handoff] = [h for h, ok in zip(handoffs, results) if ok]
+ enabled: list[Handoff] = [h for h, ok in zip(handoffs, results, strict=False) if ok]
return enabled
diff --git a/src/agents/run_internal/turn_resolution.py b/src/agents/run_internal/turn_resolution.py
index c34c720f..879f3002 100644
--- a/src/agents/run_internal/turn_resolution.py
+++ b/src/agents/run_internal/turn_resolution.py
@@ -73,6 +73,7 @@ from ..stream_events import StreamEvent
from ..tool import (
ApplyPatchTool,
ComputerTool,
+ CustomTool,
FunctionTool,
FunctionToolResult,
HostedMCPTool,
@@ -84,6 +85,7 @@ from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResul
from ..tracing import SpanError, handoff_span
from ..util import _coro, _error_tracing
from ..util._approvals import evaluate_needs_approval_setting
+from .agent_bindings import AgentBindings
from .items import (
REJECTION_MESSAGE,
apply_patch_rejection_item,
@@ -101,6 +103,7 @@ from .run_steps import (
SingleStepResult,
ToolRunApplyPatchCall,
ToolRunComputerAction,
+ ToolRunCustom,
ToolRunFunction,
ToolRunHandoff,
ToolRunLocalShellCall,
@@ -110,7 +113,7 @@ from .run_steps import (
from .streaming import stream_step_items_to_queue
from .tool_execution import (
build_litellm_json_tool_call,
- coerce_apply_patch_operation,
+ coerce_apply_patch_operations,
coerce_shell_call,
extract_apply_patch_call_id,
extract_shell_call_id,
@@ -155,7 +158,7 @@ __all__ = [
async def _maybe_finalize_from_tool_results(
*,
- agent: Agent[TContext],
+ public_agent: Agent[TContext],
original_input: str | list[TResponseInputItem],
new_response: ModelResponse,
pre_step_items: list[RunItem],
@@ -167,12 +170,12 @@ async def _maybe_finalize_from_tool_results(
tool_output_guardrail_results: list[ToolOutputGuardrailResult],
) -> SingleStepResult | None:
check_tool_use = await check_for_final_output_from_tools(
- agent, function_results, context_wrapper
+ public_agent, function_results, context_wrapper
)
if not check_tool_use.is_final_output:
return None
- if not agent.output_type or agent.output_type is str:
+ if not public_agent.output_type or public_agent.output_type is str:
check_tool_use.final_output = str(check_tool_use.final_output)
if check_tool_use.final_output is None:
@@ -182,7 +185,7 @@ async def _maybe_finalize_from_tool_results(
)
return await execute_final_output(
- agent=agent,
+ public_agent=public_agent,
original_input=original_input,
new_response=new_response,
pre_step_items=pre_step_items,
@@ -218,7 +221,7 @@ async def run_final_output_hooks(
async def execute_final_output_step(
*,
- agent: Agent[Any],
+ public_agent: Agent[Any],
original_input: str | list[TResponseInputItem],
new_response: ModelResponse,
pre_step_items: list[RunItem],
@@ -235,7 +238,7 @@ async def execute_final_output_step(
) -> SingleStepResult:
"""Finalize a turn once final output is known and run end hooks."""
final_output_hooks = run_final_output_hooks_fn or run_final_output_hooks
- await final_output_hooks(agent, hooks, context_wrapper, final_output)
+ await final_output_hooks(public_agent, hooks, context_wrapper, final_output)
return SingleStepResult(
original_input=original_input,
@@ -251,7 +254,7 @@ async def execute_final_output_step(
async def execute_final_output(
*,
- agent: Agent[Any],
+ public_agent: Agent[Any],
original_input: str | list[TResponseInputItem],
new_response: ModelResponse,
pre_step_items: list[RunItem],
@@ -268,7 +271,7 @@ async def execute_final_output(
) -> SingleStepResult:
"""Convenience wrapper to finalize a turn and run end hooks."""
return await execute_final_output_step(
- agent=agent,
+ public_agent=public_agent,
original_input=original_input,
new_response=new_response,
pre_step_items=pre_step_items,
@@ -284,7 +287,7 @@ async def execute_final_output(
async def execute_handoffs(
*,
- agent: Agent[TContext],
+ public_agent: Agent[TContext],
original_input: str | list[TResponseInputItem],
pre_step_items: list[RunItem],
new_step_items: list[RunItem],
@@ -310,14 +313,14 @@ async def execute_handoffs(
ToolCallOutputItem(
output=output_message,
raw_item=ItemHelpers.tool_call_output_item(handoff.tool_call, output_message),
- agent=agent,
+ agent=public_agent,
)
for handoff in run_handoffs[1:]
]
)
actual_handoff = run_handoffs[0]
- with handoff_span(from_agent=agent.name) as span_handoff:
+ with handoff_span(from_agent=public_agent.name) as span_handoff:
handoff = actual_handoff.handoff
new_agent: Agent[Any] = await handoff.on_invoke_handoff(
context_wrapper, actual_handoff.tool_call.arguments
@@ -336,12 +339,12 @@ async def execute_handoffs(
new_step_items.append(
HandoffOutputItem(
- agent=agent,
+ agent=public_agent,
raw_item=ItemHelpers.tool_call_output_item(
actual_handoff.tool_call,
handoff.get_transfer_message(new_agent),
),
- source_agent=agent,
+ source_agent=public_agent,
target_agent=new_agent,
)
)
@@ -349,16 +352,16 @@ async def execute_handoffs(
await asyncio.gather(
hooks.on_handoff(
context=context_wrapper,
- from_agent=agent,
+ from_agent=public_agent,
to_agent=new_agent,
),
(
- agent.hooks.on_handoff(
+ public_agent.hooks.on_handoff(
context_wrapper,
agent=new_agent,
- source=agent,
+ source=public_agent,
)
- if agent.hooks
+ if public_agent.hooks
else _coro.noop_coroutine()
),
)
@@ -386,7 +389,7 @@ async def execute_handoffs(
if input_filter and handoff_input_data is not None:
filter_name = getattr(input_filter, "__qualname__", repr(input_filter))
- from_agent = getattr(agent, "name", agent.__class__.__name__)
+ from_agent = getattr(public_agent, "name", public_agent.__class__.__name__)
to_agent = getattr(new_agent, "name", new_agent.__class__.__name__)
logger.debug(
"Filtering handoff inputs with %s for %s -> %s",
@@ -498,7 +501,7 @@ async def check_for_final_output_from_tools(
async def execute_tools_and_side_effects(
*,
- agent: Agent[TContext],
+ bindings: AgentBindings[TContext],
original_input: str | list[TResponseInputItem],
pre_step_items: list[RunItem],
new_response: ModelResponse,
@@ -509,6 +512,7 @@ async def execute_tools_and_side_effects(
run_config: RunConfig,
) -> SingleStepResult:
"""Run one turn of the loop, coordinating tools, approvals, guardrails, and handoffs."""
+ public_agent = bindings.public_agent
execute_final_output_call = execute_final_output
execute_handoffs_call = execute_handoffs
@@ -518,7 +522,7 @@ async def execute_tools_and_side_effects(
plan = _build_plan_for_fresh_turn(
processed_response=processed_response,
- agent=agent,
+ agent=public_agent,
context_wrapper=context_wrapper,
approval_items_by_call_id=approval_items_by_call_id,
)
@@ -533,12 +537,13 @@ async def execute_tools_and_side_effects(
tool_input_guardrail_results,
tool_output_guardrail_results,
computer_results,
+ custom_tool_results,
shell_results,
apply_patch_results,
local_shell_results,
) = await _execute_tool_plan(
plan=plan,
- agent=agent,
+ bindings=bindings,
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
@@ -547,6 +552,7 @@ async def execute_tools_and_side_effects(
_build_tool_result_items(
function_results=function_results,
computer_results=computer_results,
+ custom_tool_results=custom_tool_results,
shell_results=shell_results,
apply_patch_results=apply_patch_results,
local_shell_results=local_shell_results,
@@ -555,6 +561,7 @@ async def execute_tools_and_side_effects(
interruptions = _collect_tool_interruptions(
function_results=function_results,
+ custom_tool_results=custom_tool_results,
shell_results=shell_results,
apply_patch_results=apply_patch_results,
)
@@ -579,7 +586,7 @@ async def execute_tools_and_side_effects(
)
await _append_mcp_callback_results(
- agent=agent,
+ agent=public_agent,
requests=plan.mcp_requests_with_callback,
context_wrapper=context_wrapper,
append_item=new_step_items.append,
@@ -587,7 +594,7 @@ async def execute_tools_and_side_effects(
if run_handoffs := processed_response.handoffs:
return await execute_handoffs_call(
- agent=agent,
+ public_agent=public_agent,
original_input=original_input,
pre_step_items=pre_step_items,
new_step_items=new_step_items,
@@ -599,7 +606,7 @@ async def execute_tools_and_side_effects(
)
tool_final_output = await _maybe_finalize_from_tool_results(
- agent=agent,
+ public_agent=public_agent,
original_input=original_input,
new_response=new_response,
pre_step_items=pre_step_items,
@@ -626,7 +633,7 @@ async def execute_tools_and_side_effects(
if output_schema and not output_schema.is_plain_text() and potential_final_output_text:
final_output = output_schema.validate_json(potential_final_output_text)
return await execute_final_output_call(
- agent=agent,
+ public_agent=public_agent,
original_input=original_input,
new_response=new_response,
pre_step_items=pre_step_items,
@@ -639,7 +646,7 @@ async def execute_tools_and_side_effects(
)
if not output_schema or output_schema.is_plain_text():
return await execute_final_output_call(
- agent=agent,
+ public_agent=public_agent,
original_input=original_input,
new_response=new_response,
pre_step_items=pre_step_items,
@@ -664,7 +671,7 @@ async def execute_tools_and_side_effects(
async def resolve_interrupted_turn(
*,
- agent: Agent[TContext],
+ bindings: AgentBindings[TContext],
original_input: str | list[TResponseInputItem],
original_pre_step_items: list[RunItem],
new_response: ModelResponse,
@@ -676,6 +683,8 @@ async def resolve_interrupted_turn(
nest_handoff_history_fn: Callable[..., HandoffInputData] | None = None,
) -> SingleStepResult:
"""Continue a turn that was previously interrupted waiting for tool approval."""
+ public_agent = bindings.public_agent
+ execution_agent = bindings.execution_agent
execute_handoffs_call = execute_handoffs
@@ -719,7 +728,7 @@ async def resolve_interrupted_turn(
)
rejected_function_outputs.append(
function_rejection_item(
- agent,
+ public_agent,
tool_call,
rejection_message=rejection_message,
scope_id=tool_state_scope_id,
@@ -770,6 +779,12 @@ async def resolve_interrupted_turn(
def _apply_patch_call_id_from_run(run: ToolRunApplyPatchCall) -> str:
return extract_apply_patch_call_id(run.tool_call)
+ def _custom_call_id_from_run(run: ToolRunCustom) -> str:
+ call_id = extract_tool_call_id(run.tool_call)
+ if not call_id:
+ raise ModelBehaviorError("Custom tool call is missing call_id.")
+ return call_id
+
def _computer_call_id_from_run(run: ToolRunComputerAction) -> str:
call_id = extract_tool_call_id(run.tool_call)
if not call_id:
@@ -782,6 +797,9 @@ async def resolve_interrupted_turn(
def _apply_patch_tool_name(run: ToolRunApplyPatchCall) -> str:
return run.apply_patch_tool.name
+ def _custom_tool_name(run: ToolRunCustom) -> str:
+ return run.custom_tool.name
+
async def _build_shell_rejection(run: ToolRunShellCall, call_id: str) -> RunItem:
rejection_message = await resolve_approval_rejection_message(
context_wrapper=context_wrapper,
@@ -793,7 +811,7 @@ async def resolve_interrupted_turn(
return cast(
RunItem,
shell_rejection_item(
- agent,
+ public_agent,
call_id,
rejection_message=rejection_message,
),
@@ -810,12 +828,34 @@ async def resolve_interrupted_turn(
return cast(
RunItem,
apply_patch_rejection_item(
- agent,
+ public_agent,
call_id,
+ output_type="apply_patch_call_output",
rejection_message=rejection_message,
),
)
+ async def _build_custom_rejection(run: ToolRunCustom, call_id: str) -> RunItem:
+ rejection_message = await resolve_approval_rejection_message(
+ context_wrapper=context_wrapper,
+ run_config=run_config,
+ tool_type="custom",
+ tool_name=run.custom_tool.name,
+ call_id=call_id,
+ )
+ return ToolCallOutputItem(
+ agent=public_agent,
+ output=rejection_message,
+ raw_item=cast(
+ Any,
+ {
+ "type": "custom_tool_call_output",
+ "call_id": call_id,
+ "output": rejection_message,
+ },
+ ),
+ )
+
async def _shell_needs_approval(run: ToolRunShellCall) -> bool:
shell_call = coerce_shell_call(run.tool_call)
return await evaluate_needs_approval_setting(
@@ -826,13 +866,28 @@ async def resolve_interrupted_turn(
)
async def _apply_patch_needs_approval(run: ToolRunApplyPatchCall) -> bool:
- operation = coerce_apply_patch_operation(
+ operations = coerce_apply_patch_operations(
run.tool_call,
context_wrapper=context_wrapper,
)
call_id = extract_apply_patch_call_id(run.tool_call)
+ for operation in operations:
+ if await evaluate_needs_approval_setting(
+ run.apply_patch_tool.needs_approval, context_wrapper, operation, call_id
+ ):
+ return True
+ return False
+
+ async def _custom_tool_needs_approval(run: ToolRunCustom) -> bool:
+ tool_input = get_mapping_or_attr(run.tool_call, "input")
+ call_id = _custom_call_id_from_run(run)
+ if not isinstance(tool_input, str):
+ raise ModelBehaviorError("Custom tool call is missing input.")
return await evaluate_needs_approval_setting(
- run.apply_patch_tool.needs_approval, context_wrapper, operation, call_id
+ run.custom_tool.runtime_needs_approval(),
+ context_wrapper,
+ tool_input,
+ call_id,
)
def _shell_output_exists(call_id: str) -> bool:
@@ -841,6 +896,9 @@ async def resolve_interrupted_turn(
def _apply_patch_output_exists(call_id: str) -> bool:
return _has_output_item(call_id, "apply_patch_call_output")
+ def _custom_tool_output_exists(call_id: str) -> bool:
+ return _has_output_item(call_id, "custom_tool_call_output")
+
def _computer_output_exists(call_id: str) -> bool:
return _has_output_item(call_id, "computer_call_output")
@@ -893,20 +951,39 @@ async def resolve_interrupted_turn(
pending_interruption_keys.add(key)
pending_interruptions.append(item)
+ def _allow_legacy_name_agent_match() -> bool:
+ schema_version = getattr(run_state, "_schema_version", None)
+ if not isinstance(schema_version, str):
+ return False
+ try:
+ version_parts = tuple(int(part) for part in schema_version.split("."))
+ except ValueError:
+ return False
+ # Schema 1.6 and earlier only serialized approval owners by agent name. With duplicate-name
+ # agents, deserialization can legitimately resolve the approval to a sibling instance, so
+ # resume must accept a same-name match for those legacy snapshots. Schema 1.7+ persists
+ # duplicate-name identities, so newer snapshots should continue requiring object identity.
+ return version_parts < (1, 7)
+
+ allow_legacy_name_agent_match = _allow_legacy_name_agent_match()
+
def _approval_matches_agent(approval: ToolApprovalItem) -> bool:
approval_agent = approval.agent
if approval_agent is None:
return False
- if approval_agent is agent:
+ if approval_agent is public_agent:
return True
- return getattr(approval_agent, "name", None) == agent.name
+ return allow_legacy_name_agent_match and approval_agent.name == public_agent.name
- available_function_tools = await resolve_enabled_function_tools(agent, context_wrapper)
+ available_function_tools = await resolve_enabled_function_tools(
+ execution_agent,
+ context_wrapper,
+ )
approval_rebuild_function_tools = available_function_tools
- if pending_approval_items and agent.mcp_servers:
+ if pending_approval_items and execution_agent.mcp_servers:
approval_rebuild_function_tools = [
tool
- for tool in await agent.get_all_tools(context_wrapper)
+ for tool in await execution_agent.get_all_tools(context_wrapper)
if isinstance(tool, FunctionTool)
]
@@ -1030,7 +1107,7 @@ async def resolve_interrupted_turn(
record_rejection=_record_function_rejection,
pending_interruption_adder=_add_pending_interruption,
pending_item_builder=lambda run: ToolApprovalItem(
- agent=agent,
+ agent=public_agent,
raw_item=run.tool_call,
tool_name=run.function_tool.name,
tool_namespace=get_tool_call_namespace(run.tool_call),
@@ -1071,7 +1148,7 @@ async def resolve_interrupted_turn(
rejection_builder=_build_shell_rejection,
context_wrapper=context_wrapper,
approval_items_by_call_id=approval_items_by_call_id,
- agent=agent,
+ agent=public_agent,
pending_interruption_adder=_add_pending_interruption,
needs_approval_checker=_shell_needs_approval,
output_exists_checker=_shell_output_exists,
@@ -1084,21 +1161,35 @@ async def resolve_interrupted_turn(
rejection_builder=_build_apply_patch_rejection,
context_wrapper=context_wrapper,
approval_items_by_call_id=approval_items_by_call_id,
- agent=agent,
+ agent=public_agent,
pending_interruption_adder=_add_pending_interruption,
needs_approval_checker=_apply_patch_needs_approval,
output_exists_checker=_apply_patch_output_exists,
)
+ approved_custom_tool_calls, rejected_custom_tool_results = await _collect_runs_by_approval(
+ processed_response.custom_tool_calls,
+ call_id_extractor=_custom_call_id_from_run,
+ tool_name_resolver=_custom_tool_name,
+ rejection_builder=_build_custom_rejection,
+ context_wrapper=context_wrapper,
+ approval_items_by_call_id=approval_items_by_call_id,
+ agent=public_agent,
+ pending_interruption_adder=_add_pending_interruption,
+ needs_approval_checker=_custom_tool_needs_approval,
+ output_exists_checker=_custom_tool_output_exists,
+ )
+
plan = _build_plan_for_resume_turn(
processed_response=processed_response,
- agent=agent,
+ agent=public_agent,
context_wrapper=context_wrapper,
approval_items_by_call_id=approval_items_by_call_id,
pending_interruptions=pending_interruptions,
pending_interruption_adder=_add_pending_interruption,
function_runs=function_tool_runs,
computer_actions=pending_computer_actions,
+ custom_tool_calls=approved_custom_tool_calls,
shell_calls=approved_shell_calls,
apply_patch_calls=approved_apply_patch_calls,
)
@@ -1108,12 +1199,13 @@ async def resolve_interrupted_turn(
tool_input_guardrail_results,
tool_output_guardrail_results,
computer_results,
+ custom_tool_results,
shell_results,
apply_patch_results,
_local_shell_results,
) = await _execute_tool_plan(
plan=plan,
- agent=agent,
+ bindings=bindings,
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
@@ -1121,6 +1213,7 @@ async def resolve_interrupted_turn(
for interruption in _collect_tool_interruptions(
function_results=function_results,
+ custom_tool_results=custom_tool_results,
shell_results=[],
apply_patch_results=[],
):
@@ -1131,6 +1224,7 @@ async def resolve_interrupted_turn(
for item in _build_tool_result_items(
function_results=function_results,
computer_results=computer_results,
+ custom_tool_results=custom_tool_results,
shell_results=shell_results,
apply_patch_results=apply_patch_results,
local_shell_results=[],
@@ -1143,6 +1237,8 @@ async def resolve_interrupted_turn(
append_if_new(pending_item)
for shell_rejection in rejected_shell_results:
append_if_new(shell_rejection)
+ for custom_tool_rejection in rejected_custom_tool_results:
+ append_if_new(custom_tool_rejection)
for apply_patch_rejection in rejected_apply_patch_results:
append_if_new(apply_patch_rejection)
for approved_response in plan.approved_mcp_responses:
@@ -1164,7 +1260,7 @@ async def resolve_interrupted_turn(
)
await _append_mcp_callback_results(
- agent=agent,
+ agent=public_agent,
requests=plan.mcp_requests_with_callback,
context_wrapper=context_wrapper,
append_item=append_if_new,
@@ -1177,7 +1273,7 @@ async def resolve_interrupted_turn(
original_pre_step_items=original_pre_step_items,
mcp_approval_requests=processed_response.mcp_approval_requests,
context_wrapper=context_wrapper,
- agent=agent,
+ agent=public_agent,
append_item=append_if_new,
)
@@ -1232,7 +1328,7 @@ async def resolve_interrupted_turn(
if pending_handoffs:
return await execute_handoffs_call(
- agent=agent,
+ public_agent=public_agent,
original_input=original_input,
pre_step_items=pre_step_items,
new_step_items=new_items,
@@ -1245,7 +1341,7 @@ async def resolve_interrupted_turn(
)
tool_final_output = await _maybe_finalize_from_tool_results(
- agent=agent,
+ public_agent=public_agent,
original_input=original_input,
new_response=new_response,
pre_step_items=pre_step_items,
@@ -1284,6 +1380,7 @@ def process_model_response(
run_handoffs = []
functions = []
computer_actions = []
+ custom_tool_calls = []
local_shell_calls = []
shell_calls = []
apply_patch_calls = []
@@ -1293,6 +1390,7 @@ def process_model_response(
function_map = build_function_tool_lookup_map(
[tool for tool in all_tools if isinstance(tool, FunctionTool)]
)
+ custom_tool_map = {tool.name: tool for tool in all_tools if isinstance(tool, CustomTool)}
computer_tool = next((tool for tool in all_tools if isinstance(tool, ComputerTool)), None)
local_shell_tool = next((tool for tool in all_tools if isinstance(tool, LocalShellTool)), None)
shell_tool = next((tool for tool in all_tools if isinstance(tool, ShellTool)), None)
@@ -1373,7 +1471,7 @@ def process_model_response(
shell_calls.append(ToolRunShellCall(tool_call=output, shell_tool=shell_tool))
continue
if output_type == "shell_call_output" and isinstance(
- output, (dict, ResponseFunctionShellToolCallOutput)
+ output, dict | ResponseFunctionShellToolCallOutput
):
tools_used.append(shell_tool.name if shell_tool else "shell")
if isinstance(output, dict):
@@ -1553,35 +1651,48 @@ def process_model_response(
raise ModelBehaviorError(
"Model produced local shell call without a local shell tool."
)
- elif isinstance(output, ResponseCustomToolCall) and is_apply_patch_name(
- output.name, apply_patch_tool
- ):
- parsed_operation = parse_apply_patch_custom_input(output.input)
- pseudo_call = {
- "type": "apply_patch_call",
- "call_id": output.call_id,
- "operation": parsed_operation,
- }
- items.append(ToolCallItem(raw_item=cast(Any, pseudo_call), agent=agent))
- if apply_patch_tool:
- tools_used.append(apply_patch_tool.name)
- apply_patch_calls.append(
- ToolRunApplyPatchCall(
- tool_call=pseudo_call,
- apply_patch_tool=apply_patch_tool,
+ elif isinstance(output, ResponseCustomToolCall):
+ custom_tool = custom_tool_map.get(output.name)
+ if custom_tool is not None:
+ items.append(ToolCallItem(raw_item=cast(Any, output), agent=agent))
+ tools_used.append(custom_tool.name)
+ custom_tool_calls.append(ToolRunCustom(tool_call=output, custom_tool=custom_tool))
+ elif is_apply_patch_name(output.name, apply_patch_tool):
+ parsed_operation = parse_apply_patch_custom_input(output.input)
+ pseudo_call = {
+ "type": "apply_patch_call",
+ "call_id": output.call_id,
+ **parsed_operation,
+ }
+ items.append(ToolCallItem(raw_item=cast(Any, pseudo_call), agent=agent))
+ if apply_patch_tool:
+ tools_used.append(apply_patch_tool.name)
+ apply_patch_calls.append(
+ ToolRunApplyPatchCall(
+ tool_call=pseudo_call,
+ apply_patch_tool=apply_patch_tool,
+ )
+ )
+ else:
+ tools_used.append("apply_patch")
+ _error_tracing.attach_error_to_current_span(
+ SpanError(
+ message="Apply patch tool not found",
+ data={},
+ )
+ )
+ raise ModelBehaviorError(
+ "Model produced apply_patch call without an apply_patch tool."
)
- )
else:
- tools_used.append("apply_patch")
+ items.append(ToolCallItem(raw_item=cast(Any, output), agent=agent))
_error_tracing.attach_error_to_current_span(
SpanError(
- message="Apply patch tool not found",
- data={},
+ message="Custom tool not found",
+ data={"tool_name": output.name},
)
)
- raise ModelBehaviorError(
- "Model produced apply_patch call without an apply_patch tool."
- )
+ raise ModelBehaviorError(f"Tool {output.name} not found in agent {agent.name}")
elif (
isinstance(output, ResponseFunctionToolCall)
and is_apply_patch_name(output.name, apply_patch_tool)
@@ -1673,6 +1784,7 @@ def process_model_response(
handoffs=run_handoffs,
functions=functions,
computer_actions=computer_actions,
+ custom_tool_calls=custom_tool_calls,
local_shell_calls=local_shell_calls,
shell_calls=shell_calls,
apply_patch_calls=apply_patch_calls,
@@ -1684,7 +1796,7 @@ def process_model_response(
async def get_single_step_result_from_response(
*,
- agent: Agent[TContext],
+ bindings: AgentBindings[TContext],
all_tools: list[Tool],
original_input: str | list[TResponseInputItem],
pre_step_items: list[RunItem],
@@ -1697,8 +1809,9 @@ async def get_single_step_result_from_response(
tool_use_tracker,
event_queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] | None = None,
) -> SingleStepResult:
+ item_agent = bindings.public_agent
processed_response = process_model_response(
- agent=agent,
+ agent=item_agent,
all_tools=all_tools,
response=new_response,
output_schema=output_schema,
@@ -1706,7 +1819,7 @@ async def get_single_step_result_from_response(
existing_items=pre_step_items,
)
- tool_use_tracker.record_processed_response(agent, processed_response)
+ tool_use_tracker.record_processed_response(item_agent, processed_response)
if event_queue is not None and processed_response.new_items:
handoff_items = [
@@ -1716,7 +1829,7 @@ async def get_single_step_result_from_response(
stream_step_items_to_queue(cast(list[RunItem], handoff_items), event_queue)
return await execute_tools_and_side_effects(
- agent=agent,
+ bindings=bindings,
original_input=original_input,
pre_step_items=pre_step_items,
new_response=new_response,
diff --git a/src/agents/run_state.py b/src/agents/run_state.py
index dcda9e07..c6067f22 100644
--- a/src/agents/run_state.py
+++ b/src/agents/run_state.py
@@ -2,19 +2,25 @@
from __future__ import annotations
+import asyncio
import copy
import dataclasses
import json
+import threading
from collections import deque
-from collections.abc import Callable, Mapping, Sequence
+from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Any, Generic, Literal, Optional, Union, cast
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Generic, Literal, cast
from uuid import uuid4
from openai.types.responses import (
ResponseComputerToolCall,
+ ResponseCustomToolCall,
ResponseFunctionToolCall,
ResponseOutputMessage,
+ ResponseOutputRefusal,
+ ResponseOutputText,
ResponseReasoningItem,
)
from openai.types.responses.response_input_param import (
@@ -42,6 +48,7 @@ from ._tool_identity import (
get_function_tool_qualified_name,
serialize_function_tool_lookup_key,
)
+from .agent import Agent
from .exceptions import UserError
from .guardrail import (
GuardrailFunctionOutput,
@@ -73,9 +80,12 @@ from .items import (
)
from .logger import logger
from .run_context import RunContextWrapper
+from .sandbox.capabilities.capability import Capability
+from .sandbox.session.base_sandbox_session import BaseSandboxSession
from .tool import (
ApplyPatchTool,
ComputerTool,
+ CustomTool,
FunctionTool,
HostedMCPTool,
LocalShellTool,
@@ -96,7 +106,6 @@ from .usage import deserialize_usage, serialize_usage
from .util._json import _to_dump_compatible
if TYPE_CHECKING:
- from .agent import Agent
from .guardrail import InputGuardrailResult, OutputGuardrailResult
from .items import ModelResponse, RunItem
from .run_internal.run_steps import (
@@ -106,7 +115,7 @@ if TYPE_CHECKING:
TContext = TypeVar("TContext", default=Any)
TAgent = TypeVar("TAgent", bound="Agent[Any]", default="Agent[Any]")
-ContextOverride = Union[Mapping[str, Any], RunContextWrapper[Any]]
+ContextOverride = Mapping[str, Any] | RunContextWrapper[Any]
ContextSerializer = Callable[[Any], Mapping[str, Any]]
ContextDeserializer = Callable[[Mapping[str, Any]], Any]
@@ -118,21 +127,50 @@ ContextDeserializer = Callable[[Mapping[str, Any]], Any]
# 3. to_json() always emits CURRENT_SCHEMA_VERSION.
# 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported
# versions).
-CURRENT_SCHEMA_VERSION = "1.6"
-SUPPORTED_SCHEMA_VERSIONS = frozenset(
- {"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", CURRENT_SCHEMA_VERSION}
-)
+CURRENT_SCHEMA_VERSION = "1.9"
+# Keep this mapping in chronological order. Every schema bump must add a one-line summary here.
+SCHEMA_VERSION_SUMMARIES: dict[str, str] = {
+ "1.0": "Initial RunState snapshot format for HITL pause/resume flows.",
+ "1.1": "Same payload as 1.0, but introduces explicit backward-read support policy.",
+ "1.2": "Persists reasoning_item_id_policy for resumed and streamed follow-up turns.",
+ "1.3": "Updates resumed trace semantics to reattach traces without duplicate starts.",
+ "1.4": "Stores request_id alongside each serialized model response.",
+ "1.5": "Renumbered unreleased baseline for tool-search snapshots and richer tool metadata.",
+ "1.6": "Persists explicit approval rejection messages across resume flows.",
+ "1.7": (
+ "Persists duplicate-name agent identities across agent-owned state "
+ "and sandbox resume state."
+ ),
+ "1.8": "Persists SDK-generated prompt cache keys across resume flows.",
+ "1.9": "Persists pending custom tool calls across resume flows.",
+}
+SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES)
+
+if CURRENT_SCHEMA_VERSION not in SCHEMA_VERSION_SUMMARIES:
+ raise AssertionError(
+ "CURRENT_SCHEMA_VERSION must have a matching entry in SCHEMA_VERSION_SUMMARIES."
+ )
+
+_missing_schema_version_summaries = [
+ version for version, summary in SCHEMA_VERSION_SUMMARIES.items() if not summary.strip()
+]
+if _missing_schema_version_summaries:
+ raise AssertionError(
+ "Every supported RunState schema version must have a non-empty summary. "
+ f"Missing summaries: {', '.join(_missing_schema_version_summaries)}"
+ )
_FUNCTION_OUTPUT_ADAPTER: TypeAdapter[FunctionCallOutput] = TypeAdapter(FunctionCallOutput)
_COMPUTER_OUTPUT_ADAPTER: TypeAdapter[ComputerCallOutput] = TypeAdapter(ComputerCallOutput)
_LOCAL_SHELL_OUTPUT_ADAPTER: TypeAdapter[LocalShellCallOutput] = TypeAdapter(LocalShellCallOutput)
_TOOL_CALL_OUTPUT_UNION_ADAPTER: TypeAdapter[
FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput
-] = TypeAdapter(Union[FunctionCallOutput, ComputerCallOutput, LocalShellCallOutput])
+] = TypeAdapter(FunctionCallOutput | ComputerCallOutput | LocalShellCallOutput)
_MCP_APPROVAL_RESPONSE_ADAPTER: TypeAdapter[McpApprovalResponse] = TypeAdapter(McpApprovalResponse)
_HANDOFF_OUTPUT_ADAPTER: TypeAdapter[TResponseInputItem] = TypeAdapter(TResponseInputItem)
_LOCAL_SHELL_CALL_ADAPTER: TypeAdapter[LocalShellCall] = TypeAdapter(LocalShellCall)
_MISSING_CONTEXT_SENTINEL = object()
+_ALLOWED_MISSING_MESSAGE_FIELDS = frozenset({"status"})
@dataclass
@@ -157,6 +195,9 @@ class RunState(Generic[TContext, TAgent]):
_current_agent: TAgent | None = None
"""The agent currently handling the conversation."""
+ _starting_agent: TAgent | None = field(default=None, repr=False)
+ """The root agent used to derive stable duplicate-name identities during resume."""
+
_original_input: str | list[Any] = field(default_factory=list)
"""Original user input prior to any processing."""
@@ -184,6 +225,9 @@ class RunState(Generic[TContext, TAgent]):
_auto_previous_response_id: bool = False
"""Whether the previous response id should be automatically tracked."""
+ _generated_prompt_cache_key: str | None = None
+ """SDK-generated prompt cache key to preserve across resume flows."""
+
_reasoning_item_id_policy: Literal["preserve", "omit"] | None = None
"""How reasoning item IDs are represented in next-turn model input."""
@@ -220,6 +264,12 @@ class RunState(Generic[TContext, TAgent]):
_agent_tool_state_scope_id: str | None = field(default=None, repr=False)
"""Private scope id used to isolate agent-tool pending state per RunState instance."""
+ _sandbox: dict[str, Any] | None = field(default=None, repr=False)
+ """Serialized sandbox resume payload for sandbox-aware runs."""
+
+ _schema_version: str = field(default=CURRENT_SCHEMA_VERSION, repr=False)
+ """Schema version the snapshot was loaded from for schema-gated resume compatibility."""
+
def __init__(
self,
context: RunContextWrapper[TContext],
@@ -234,11 +284,13 @@ class RunState(Generic[TContext, TAgent]):
"""Initialize a new RunState."""
self._context = context
self._original_input = _clone_original_input(original_input)
+ self._starting_agent = starting_agent
self._current_agent = starting_agent
self._max_turns = max_turns
self._conversation_id = conversation_id
self._previous_response_id = previous_response_id
self._auto_previous_response_id = auto_previous_response_id
+ self._generated_prompt_cache_key = None
self._reasoning_item_id_policy = None
self._model_responses = []
self._generated_items = []
@@ -254,6 +306,8 @@ class RunState(Generic[TContext, TAgent]):
self._current_turn_persisted_item_count = 0
self._tool_use_tracker_snapshot = {}
self._trace_state = None
+ self._sandbox = None
+ self._schema_version = CURRENT_SCHEMA_VERSION
from .agent_tool_state import get_agent_tool_state_scope
self._agent_tool_state_scope_id = get_agent_tool_state_scope(context)
@@ -498,8 +552,14 @@ class RunState(Generic[TContext, TAgent]):
latest_response_id = (
self._model_responses[-1].response_id if self._model_responses else None
)
+ agent_identity_keys_by_id = (
+ _build_agent_identity_keys_by_id(cast(Agent[Any], self._starting_agent))
+ if self._starting_agent is not None
+ else None
+ )
serialized_items = [
- self._serialize_item(item) for item in self._last_processed_response.new_items
+ self._serialize_item(item, agent_identity_keys_by_id=agent_identity_keys_by_id)
+ for item in self._last_processed_response.new_items
]
return json.dumps(
{
@@ -633,19 +693,33 @@ class RunState(Generic[TContext, TAgent]):
if tool_input is not None:
context_entry["tool_input"] = tool_input
+ agent_identity_keys_by_id = (
+ _build_agent_identity_keys_by_id(cast(Agent[Any], self._starting_agent))
+ if self._starting_agent is not None
+ else None
+ )
+ current_agent_entry = _serialize_agent_reference(
+ cast(Agent[Any], self._current_agent),
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
+ )
+
result = {
"$schemaVersion": CURRENT_SCHEMA_VERSION,
"current_turn": self._current_turn,
- "current_agent": {"name": self._current_agent.name},
+ "current_agent": current_agent_entry,
"original_input": original_input_serialized,
"model_responses": model_responses,
"context": context_entry,
"tool_use_tracker": copy.deepcopy(self._tool_use_tracker_snapshot),
"max_turns": self._max_turns,
"no_active_agent_run": True,
- "input_guardrail_results": _serialize_guardrail_results(self._input_guardrail_results),
+ "input_guardrail_results": _serialize_guardrail_results(
+ self._input_guardrail_results,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
+ ),
"output_guardrail_results": _serialize_guardrail_results(
- self._output_guardrail_results
+ self._output_guardrail_results,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
),
"tool_input_guardrail_results": _serialize_tool_guardrail_results(
self._tool_input_guardrail_results, type_label="tool_input"
@@ -656,17 +730,25 @@ class RunState(Generic[TContext, TAgent]):
"conversation_id": self._conversation_id,
"previous_response_id": self._previous_response_id,
"auto_previous_response_id": self._auto_previous_response_id,
+ "generated_prompt_cache_key": self._generated_prompt_cache_key,
"reasoning_item_id_policy": self._reasoning_item_id_policy,
}
generated_items = self._merge_generated_items_with_processed()
- result["generated_items"] = [self._serialize_item(item) for item in generated_items]
- result["session_items"] = [self._serialize_item(item) for item in list(self._session_items)]
+ result["generated_items"] = [
+ self._serialize_item(item, agent_identity_keys_by_id=agent_identity_keys_by_id)
+ for item in generated_items
+ ]
+ result["session_items"] = [
+ self._serialize_item(item, agent_identity_keys_by_id=agent_identity_keys_by_id)
+ for item in list(self._session_items)
+ ]
result["current_step"] = self._serialize_current_step()
result["last_model_response"] = _serialize_last_model_response(model_responses)
result["last_processed_response"] = (
self._serialize_processed_response(
self._last_processed_response,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
context_serializer=context_serializer,
strict_context=strict_context,
include_tracing_api_key=include_tracing_api_key,
@@ -678,6 +760,8 @@ class RunState(Generic[TContext, TAgent]):
result["trace"] = self._serialize_trace_data(
include_tracing_api_key=include_tracing_api_key
)
+ if self._sandbox is not None:
+ result["sandbox"] = copy.deepcopy(self._sandbox)
return result
@@ -685,6 +769,7 @@ class RunState(Generic[TContext, TAgent]):
self,
processed_response: ProcessedResponse,
*,
+ agent_identity_keys_by_id: Mapping[int, str] | None = None,
context_serializer: ContextSerializer | None = None,
strict_context: bool = False,
include_tracing_api_key: bool = False,
@@ -710,13 +795,20 @@ class RunState(Generic[TContext, TAgent]):
)
interruptions_data = [
- _serialize_tool_approval_interruption(interruption, include_tool_name=True)
+ _serialize_tool_approval_interruption(
+ interruption,
+ include_tool_name=True,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
+ )
for interruption in processed_response.interruptions
if isinstance(interruption, ToolApprovalItem)
]
return {
- "new_items": [self._serialize_item(item) for item in processed_response.new_items],
+ "new_items": [
+ self._serialize_item(item, agent_identity_keys_by_id=agent_identity_keys_by_id)
+ for item in processed_response.new_items
+ ],
"tools_used": processed_response.tools_used,
**action_groups,
"interruptions": interruptions_data,
@@ -727,12 +819,20 @@ class RunState(Generic[TContext, TAgent]):
# Import at runtime to avoid circular import
from .run_internal.run_steps import NextStepInterruption
+ agent_identity_keys_by_id = (
+ _build_agent_identity_keys_by_id(cast(Agent[Any], self._starting_agent))
+ if self._starting_agent is not None
+ else None
+ )
+
if self._current_step is None or not isinstance(self._current_step, NextStepInterruption):
return None
interruptions_data = [
_serialize_tool_approval_interruption(
- item, include_tool_name=item.tool_name is not None
+ item,
+ include_tool_name=item.tool_name is not None,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
)
for item in self._current_step.interruptions
if isinstance(item, ToolApprovalItem)
@@ -745,14 +845,22 @@ class RunState(Generic[TContext, TAgent]):
},
}
- def _serialize_item(self, item: RunItem) -> dict[str, Any]:
+ def _serialize_item(
+ self,
+ item: RunItem,
+ *,
+ agent_identity_keys_by_id: Mapping[int, str] | None = None,
+ ) -> dict[str, Any]:
"""Serialize a run item to JSON-compatible dict."""
raw_item_dict: Any = _serialize_raw_item_value(item.raw_item)
result: dict[str, Any] = {
"type": item.type,
"raw_item": raw_item_dict,
- "agent": {"name": item.agent.name},
+ "agent": _serialize_agent_reference(
+ item.agent,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
+ ),
}
# Add additional fields based on item type
@@ -768,9 +876,15 @@ class RunState(Generic[TContext, TAgent]):
serialized_output = str(item.output)
result["output"] = serialized_output
if hasattr(item, "source_agent"):
- result["source_agent"] = {"name": item.source_agent.name}
+ result["source_agent"] = _serialize_agent_reference(
+ item.source_agent,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
+ )
if hasattr(item, "target_agent"):
- result["target_agent"] = {"name": item.target_agent.name}
+ result["target_agent"] = _serialize_agent_reference(
+ item.target_agent,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
+ )
if hasattr(item, "tool_name") and item.tool_name is not None:
result["tool_name"] = item.tool_name
if hasattr(item, "tool_namespace") and item.tool_namespace is not None:
@@ -794,12 +908,12 @@ class RunState(Generic[TContext, TAgent]):
def _extract_name(raw: Any) -> str | None:
if isinstance(raw, dict):
- candidate_call_id = cast(Optional[str], raw.get("call_id"))
+ candidate_call_id = cast(str | None, raw.get("call_id"))
if candidate_call_id == call_id:
name_value = raw.get("name", "")
return str(name_value) if name_value else ""
else:
- candidate_call_id = cast(Optional[str], _get_attr(raw, "call_id"))
+ candidate_call_id = cast(str | None, _get_attr(raw, "call_id"))
if candidate_call_id == call_id:
name_value = _get_attr(raw, "name", "")
return str(name_value) if name_value else ""
@@ -829,7 +943,7 @@ class RunState(Generic[TContext, TAgent]):
continue
if input_item.get("type") != "function_call":
continue
- item_call_id = cast(Optional[str], input_item.get("call_id"))
+ item_call_id = cast(str | None, input_item.get("call_id"))
if item_call_id == call_id:
name_value = input_item.get("name", "")
return str(name_value) if name_value else ""
@@ -1066,7 +1180,7 @@ def _transform_field_names(
transformed: dict[str, Any] = {}
for key, value in data.items():
mapped_key = field_map.get(key, key)
- if isinstance(value, (dict, list)):
+ if isinstance(value, dict | list):
transformed[mapped_key] = _transform_field_names(value, field_map)
else:
transformed[mapped_key] = value
@@ -1074,7 +1188,7 @@ def _transform_field_names(
if isinstance(data, list):
return [
- _transform_field_names(item, field_map) if isinstance(item, (dict, list)) else item
+ _transform_field_names(item, field_map) if isinstance(item, dict | list) else item
for item in data
]
@@ -1090,6 +1204,19 @@ def _serialize_raw_item_value(raw_item: Any) -> Any:
return raw_item
+def _serialize_agent_reference(
+ agent: Agent[Any],
+ agent_identity_keys_by_id: Mapping[int, str] | None = None,
+) -> dict[str, Any]:
+ """Serialize an agent reference with an optional duplicate-name identity key."""
+ entry: dict[str, Any] = {"name": agent.name}
+ if agent_identity_keys_by_id is not None:
+ identity = agent_identity_keys_by_id.get(id(agent))
+ if identity is not None and identity != agent.name:
+ entry["identity"] = identity
+ return entry
+
+
def _ensure_json_compatible(value: Any) -> Any:
try:
return json.loads(json.dumps(value, default=str))
@@ -1214,13 +1341,19 @@ def _serialize_mcp_tool(mcp_tool: Any) -> dict[str, Any]:
def _serialize_tool_approval_interruption(
- interruption: ToolApprovalItem, *, include_tool_name: bool
+ interruption: ToolApprovalItem,
+ *,
+ include_tool_name: bool,
+ agent_identity_keys_by_id: Mapping[int, str] | None = None,
) -> dict[str, Any]:
"""Serialize a ToolApprovalItem interruption."""
interruption_dict: dict[str, Any] = {
"type": "tool_approval_item",
"raw_item": _serialize_raw_item_value(interruption.raw_item),
- "agent": {"name": interruption.agent.name},
+ "agent": _serialize_agent_reference(
+ interruption.agent,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
+ ),
}
if include_tool_name and interruption.tool_name is not None:
interruption_dict["tool_name"] = interruption.tool_name
@@ -1259,6 +1392,14 @@ def _serialize_tool_action_groups(
True,
False,
),
+ (
+ "custom_tool_actions",
+ processed_response.custom_tool_calls,
+ "custom_tool",
+ "custom_tool",
+ True,
+ False,
+ ),
(
"local_shell_actions",
processed_response.local_shell_calls,
@@ -1325,7 +1466,7 @@ def _serialize_pending_nested_agent_tool_runs(
from .agent_tool_state import peek_agent_tool_run_result
- for entry, function_run in zip(function_entries, function_runs):
+ for entry, function_run in zip(function_entries, function_runs, strict=False):
tool_call = getattr(function_run, "tool_call", None)
if not isinstance(tool_call, ResponseFunctionToolCall):
continue
@@ -1388,6 +1529,8 @@ class _SerializedAgentToolRunResult:
def _serialize_guardrail_results(
results: Sequence[InputGuardrailResult | OutputGuardrailResult],
+ *,
+ agent_identity_keys_by_id: Mapping[int, str] | None = None,
) -> list[dict[str, Any]]:
"""Serialize guardrail results for persistence."""
serialized: list[dict[str, Any]] = []
@@ -1404,7 +1547,10 @@ def _serialize_guardrail_results(
}
if isinstance(result, OutputGuardrailResult):
entry["agentOutput"] = result.agent_output
- entry["agent"] = {"name": result.agent.name}
+ entry["agent"] = _serialize_agent_reference(
+ result.agent,
+ agent_identity_keys_by_id=agent_identity_keys_by_id,
+ )
serialized.append(entry)
return serialized
@@ -1501,7 +1647,7 @@ async def _restore_pending_nested_agent_tool_runs(
from .agent_tool_state import drop_agent_tool_run_result, record_agent_tool_run_result
- for entry, function_run in zip(function_entries, function_runs):
+ for entry, function_run in zip(function_entries, function_runs, strict=False):
if not isinstance(entry, Mapping):
continue
nested_state_data = entry.get("agent_run_state")
@@ -1544,6 +1690,7 @@ async def _deserialize_processed_response(
context: RunContextWrapper[Any],
agent_map: dict[str, Agent[Any]],
*,
+ agent_identity_map: Mapping[str, Agent[Any]] | None = None,
scope_id: str | None = None,
context_deserializer: ContextDeserializer | None = None,
strict_context: bool = False,
@@ -1559,7 +1706,11 @@ async def _deserialize_processed_response(
Returns:
A reconstructed ProcessedResponse instance.
"""
- new_items = _deserialize_items(processed_response_data.get("new_items", []), agent_map)
+ new_items = _deserialize_items(
+ processed_response_data.get("new_items", []),
+ agent_map,
+ agent_identity_map=agent_identity_map,
+ )
if hasattr(current_agent, "get_all_tools"):
all_tools = await current_agent.get_all_tools(context)
@@ -1568,6 +1719,7 @@ async def _deserialize_processed_response(
tools_map = _build_named_tool_map(all_tools, FunctionTool)
computer_tools_map = _build_named_tool_map(all_tools, ComputerTool)
+ custom_tools_map = _build_named_tool_map(all_tools, CustomTool)
local_shell_tools_map = _build_named_tool_map(all_tools, LocalShellTool)
shell_tools_map = _build_named_tool_map(all_tools, ShellTool)
apply_patch_tools_map = _build_named_tool_map(all_tools, ApplyPatchTool)
@@ -1578,6 +1730,7 @@ async def _deserialize_processed_response(
ProcessedResponse,
ToolRunApplyPatchCall,
ToolRunComputerAction,
+ ToolRunCustom,
ToolRunFunction,
ToolRunHandoff,
ToolRunLocalShellCall,
@@ -1714,6 +1867,16 @@ async def _deserialize_processed_response(
),
None,
),
+ (
+ "custom_tool_actions",
+ "custom_tool",
+ custom_tools_map,
+ lambda data: ResponseCustomToolCall(**data),
+ lambda tool_call, custom_tool: ToolRunCustom(
+ tool_call=tool_call, custom_tool=custom_tool
+ ),
+ None,
+ ),
(
"local_shell_actions",
"local_shell",
@@ -1769,6 +1932,7 @@ async def _deserialize_processed_response(
handoffs = action_groups["handoffs"]
functions = action_groups["functions"]
computer_actions = action_groups["computer_actions"]
+ custom_tool_actions = action_groups["custom_tool_actions"]
local_shell_actions = action_groups["local_shell_actions"]
shell_actions = action_groups["shell_actions"]
apply_patch_actions = action_groups["apply_patch_actions"]
@@ -1811,6 +1975,7 @@ async def _deserialize_processed_response(
approval_item = _deserialize_tool_approval_item(
interruption_data,
agent_map=agent_map,
+ agent_identity_map=agent_identity_map,
fallback_agent=current_agent,
)
if approval_item is not None:
@@ -1821,6 +1986,7 @@ async def _deserialize_processed_response(
handoffs=handoffs,
functions=functions,
computer_actions=computer_actions,
+ custom_tool_calls=custom_tool_actions,
local_shell_calls=local_shell_actions,
shell_calls=shell_actions,
apply_patch_calls=apply_patch_actions,
@@ -1852,19 +2018,78 @@ def _deserialize_tool_call_raw_item(normalized_raw_item: Mapping[str, Any]) -> A
return normalized_raw_item
+def _can_construct_statusless_message(exc: ValidationError) -> bool:
+ missing_fields = {
+ str(error["loc"][0])
+ for error in exc.errors()
+ if error.get("type") == "missing"
+ and isinstance(error.get("loc"), tuple)
+ and error.get("loc")
+ }
+ if not missing_fields:
+ return False
+ return missing_fields <= _ALLOWED_MISSING_MESSAGE_FIELDS
+
+
+def _deserialize_message_content_part(value: object) -> object:
+ if not isinstance(value, Mapping):
+ return value
+
+ part_type = value.get("type")
+ if part_type == "output_text":
+ return ResponseOutputText.model_construct(**dict(value))
+ if part_type == "refusal":
+ return ResponseOutputRefusal.model_construct(**dict(value))
+ return dict(value)
+
+
+def _deserialize_message_output_item(payload: Mapping[str, Any]) -> ResponseOutputMessage:
+ try:
+ return ResponseOutputMessage(**payload)
+ except ValidationError as exc:
+ if not _can_construct_statusless_message(exc):
+ raise
+
+ content = payload.get("content")
+ normalized_content = (
+ [_deserialize_message_content_part(part) for part in content]
+ if isinstance(content, list)
+ else content
+ )
+ normalized_payload = dict(payload)
+ normalized_payload["content"] = normalized_content
+ return ResponseOutputMessage.model_construct(**normalized_payload)
+
+
def _resolve_agent_from_data(
agent_data: Any,
agent_map: Mapping[str, Agent[Any]],
+ agent_identity_map: Mapping[str, Agent[Any]] | None = None,
fallback_agent: Agent[Any] | None = None,
) -> Agent[Any] | None:
"""Resolve an agent from serialized data with an optional fallback."""
agent_name = None
+ agent_identity = None
if isinstance(agent_data, Mapping):
+ agent_identity = agent_data.get("identity")
agent_name = agent_data.get("name")
elif isinstance(agent_data, str):
agent_name = agent_data
+ if isinstance(agent_identity, str) and agent_identity_map is not None:
+ resolved = agent_identity_map.get(agent_identity)
+ if resolved is not None:
+ return resolved
+ raise UserError(
+ "Run state references an agent identity that is not present in the restored graph: "
+ f"{agent_identity}"
+ )
+
if agent_name:
+ if agent_identity_map is not None:
+ resolved = agent_identity_map.get(agent_name)
+ if resolved is not None:
+ return resolved
return agent_map.get(agent_name) or fallback_agent
return fallback_agent
@@ -1881,11 +2106,17 @@ def _deserialize_tool_approval_item(
item_data: Mapping[str, Any],
*,
agent_map: Mapping[str, Agent[Any]],
+ agent_identity_map: Mapping[str, Agent[Any]] | None = None,
fallback_agent: Agent[Any] | None = None,
pre_normalized_raw_item: Any | None = None,
) -> ToolApprovalItem | None:
"""Deserialize a ToolApprovalItem from serialized data."""
- agent = _resolve_agent_from_data(item_data.get("agent"), agent_map, fallback_agent)
+ agent = _resolve_agent_from_data(
+ item_data.get("agent"),
+ agent_map,
+ agent_identity_map,
+ fallback_agent,
+ )
if agent is None:
return None
@@ -1929,7 +2160,7 @@ def _deserialize_tool_call_output_raw_item(
return _COMPUTER_OUTPUT_ADAPTER.validate_python(normalized_raw_item)
if output_type == "local_shell_call_output":
return _LOCAL_SHELL_OUTPUT_ADAPTER.validate_python(normalized_raw_item)
- if output_type in {"shell_call_output", "apply_patch_call_output"}:
+ if output_type in {"shell_call_output", "apply_patch_call_output", "custom_tool_call_output"}:
return normalized_raw_item
try:
@@ -1976,7 +2207,7 @@ def _parse_tool_guardrail_entry(
behavior: RejectContentBehavior | RaiseExceptionBehavior | AllowBehavior
if isinstance(behavior_data, dict) and "type" in behavior_data:
behavior = cast(
- Union[RejectContentBehavior, RaiseExceptionBehavior, AllowBehavior],
+ RejectContentBehavior | RaiseExceptionBehavior | AllowBehavior,
behavior_data,
)
else:
@@ -2018,6 +2249,7 @@ def _deserialize_output_guardrail_results(
results_data: list[dict[str, Any]],
*,
agent_map: dict[str, Agent[Any]],
+ agent_identity_map: Mapping[str, Agent[Any]] | None = None,
fallback_agent: Agent[Any],
) -> list[OutputGuardrailResult]:
"""Rehydrate output guardrail results from serialized data."""
@@ -2029,9 +2261,14 @@ def _deserialize_output_guardrail_results(
name, guardrail_output, entry_dict = parsed
agent_output = entry_dict.get("agentOutput")
agent_data = entry_dict.get("agent")
- agent_name = agent_data.get("name") if isinstance(agent_data, dict) else None
- resolved_agent = agent_map.get(agent_name) if isinstance(agent_name, str) else None
- resolved_agent = resolved_agent or fallback_agent
+ resolved_agent = _resolve_agent_from_data(
+ agent_data,
+ agent_map,
+ agent_identity_map,
+ fallback_agent,
+ )
+ if resolved_agent is None:
+ resolved_agent = fallback_agent
def _output_guardrail_fn(
context: RunContextWrapper[Any],
@@ -2134,10 +2371,16 @@ async def _build_run_state_from_json(
f"New snapshots are written as version {CURRENT_SCHEMA_VERSION}."
)
+ agent_identity_map = _build_agent_identity_map(initial_agent)
agent_map = _build_agent_map(initial_agent)
- current_agent_name = state_json["current_agent"]["name"]
- current_agent = agent_map.get(current_agent_name)
+ current_agent_data = state_json["current_agent"]
+ current_agent_name = current_agent_data["name"]
+ current_agent = _resolve_agent_from_data(
+ current_agent_data,
+ agent_map,
+ agent_identity_map=agent_identity_map,
+ )
if not current_agent:
raise UserError(f"Agent {current_agent_name} not found in agent map")
@@ -2218,6 +2461,8 @@ async def _build_run_state_from_json(
previous_response_id=state_json.get("previous_response_id"),
auto_previous_response_id=bool(state_json.get("auto_previous_response_id", False)),
)
+ state._starting_agent = initial_agent
+ state._schema_version = schema_version
from .agent_tool_state import set_agent_tool_state_scope
state._agent_tool_state_scope_id = uuid4().hex
@@ -2225,7 +2470,11 @@ async def _build_run_state_from_json(
state._current_turn = state_json["current_turn"]
state._model_responses = _deserialize_model_responses(state_json.get("model_responses", []))
- state._generated_items = _deserialize_items(state_json.get("generated_items", []), agent_map)
+ state._generated_items = _deserialize_items(
+ state_json.get("generated_items", []),
+ agent_map,
+ agent_identity_map=agent_identity_map,
+ )
last_processed_response_data = state_json.get("last_processed_response")
if last_processed_response_data and state._context is not None:
@@ -2234,6 +2483,7 @@ async def _build_run_state_from_json(
current_agent,
state._context,
agent_map,
+ agent_identity_map=agent_identity_map,
scope_id=state._agent_tool_state_scope_id,
context_deserializer=context_deserializer,
strict_context=strict_context,
@@ -2242,7 +2492,11 @@ async def _build_run_state_from_json(
state._last_processed_response = None
if "session_items" in state_json:
- state._session_items = _deserialize_items(state_json.get("session_items", []), agent_map)
+ state._session_items = _deserialize_items(
+ state_json.get("session_items", []),
+ agent_map,
+ agent_identity_map=agent_identity_map,
+ )
else:
state._session_items = state._merge_generated_items_with_processed()
@@ -2254,6 +2508,7 @@ async def _build_run_state_from_json(
state._output_guardrail_results = _deserialize_output_guardrail_results(
state_json.get("output_guardrail_results", []),
agent_map=agent_map,
+ agent_identity_map=agent_identity_map,
fallback_agent=current_agent,
)
state._tool_input_guardrail_results = _deserialize_tool_input_guardrail_results(
@@ -2270,7 +2525,11 @@ async def _build_run_state_from_json(
"interruptions", current_step_data.get("interruptions", [])
)
for item_data in interruptions_data:
- approval_item = _deserialize_tool_approval_item(item_data, agent_map=agent_map)
+ approval_item = _deserialize_tool_approval_item(
+ item_data,
+ agent_map=agent_map,
+ agent_identity_map=agent_identity_map,
+ )
if approval_item is not None:
interruptions.append(approval_item)
@@ -2288,35 +2547,35 @@ async def _build_run_state_from_json(
state._reasoning_item_id_policy = cast(Literal["preserve", "omit"], serialized_policy)
else:
state._reasoning_item_id_policy = None
+ serialized_prompt_cache_key = state_json.get("generated_prompt_cache_key")
+ state._generated_prompt_cache_key = (
+ serialized_prompt_cache_key if isinstance(serialized_prompt_cache_key, str) else None
+ )
state.set_tool_use_tracker_snapshot(state_json.get("tool_use_tracker", {}))
trace_data = state_json.get("trace")
if isinstance(trace_data, Mapping):
state._trace_state = TraceState.from_json(trace_data)
else:
state._trace_state = None
+ sandbox_data = state_json.get("sandbox")
+ state._sandbox = dict(sandbox_data) if isinstance(sandbox_data, Mapping) else None
return state
-def _build_agent_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]:
- """Build a map of agent names to agents by traversing handoffs.
-
- Args:
- initial_agent: The starting agent.
-
- Returns:
- Dictionary mapping agent names to agent instances.
- """
- agent_map: dict[str, Agent[Any]] = {}
+def _iter_agent_graph(initial_agent: Agent[Any]) -> Iterator[Agent[Any]]:
+ """Yield agents reachable from the starting agent in breadth-first order."""
queue: deque[Agent[Any]] = deque([initial_agent])
+ seen_agent_ids: set[int] = set()
while queue:
current = queue.popleft()
- if current.name in agent_map:
+ current_id = id(current)
+ if current_id in seen_agent_ids:
continue
- agent_map[current.name] = current
+ seen_agent_ids.add(current_id)
+ yield current
- # Add handoff agents to the queue
for handoff_item in current.handoffs:
handoff_agent: Any | None = None
handoff_agent_name: str | None = None
@@ -2329,8 +2588,6 @@ def _build_agent_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]:
)
if isinstance(candidate_name, str):
handoff_agent_name = candidate_name
- if handoff_agent_name in agent_map:
- continue
handoff_ref = getattr(handoff_item, "_agent_ref", None)
handoff_agent = handoff_ref() if callable(handoff_ref) else None
@@ -2368,12 +2625,8 @@ def _build_agent_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]:
candidate_name = getattr(handoff_agent, "name", None)
handoff_agent_name = candidate_name if isinstance(candidate_name, str) else None
- if (
- handoff_agent is not None
- and handoff_agent_name
- and handoff_agent_name not in agent_map
- ):
- queue.append(cast(Any, handoff_agent))
+ if handoff_agent is not None and handoff_agent_name:
+ queue.append(cast(Agent[Any], handoff_agent))
# Include agent-as-tool instances so nested approvals can be restored.
tools = getattr(current, "tools", None)
@@ -2383,9 +2636,405 @@ def _build_agent_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]:
continue
tool_agent = getattr(tool, "_agent_instance", None)
tool_agent_name = getattr(tool_agent, "name", None)
- if tool_agent and tool_agent_name and tool_agent_name not in agent_map:
+ if tool_agent and tool_agent_name:
queue.append(tool_agent)
+
+def _allocate_unique_agent_identity(agent_name: str, used_identities: set[str]) -> str:
+ """Return a deterministic identity key without colliding with literal agent names."""
+ candidate = agent_name
+ next_index = 1
+ while candidate in used_identities:
+ next_index += 1
+ candidate = f"{agent_name}#{next_index}"
+ used_identities.add(candidate)
+ return candidate
+
+
+def _identity_type_name(value: Any) -> str:
+ return f"{type(value).__module__}.{type(value).__qualname__}"
+
+
+def _callable_identity_name(value: Any) -> str:
+ module = getattr(value, "__module__", type(value).__module__)
+ qualname = getattr(value, "__qualname__", type(value).__qualname__)
+ return f"{module}.{qualname}"
+
+
+def _normalize_identity_value(value: Any) -> Any:
+ if value is None or isinstance(value, str | int | float | bool):
+ return value
+ if isinstance(value, bytes | bytearray):
+ return {"type": "bytes", "length": len(value)}
+ if callable(value):
+ return {"callable": _callable_identity_name(value)}
+ if dataclasses.is_dataclass(value):
+ return {
+ "dataclass": _identity_type_name(value),
+ "value": _normalize_identity_value(dataclasses.asdict(cast(Any, value))),
+ }
+ if hasattr(value, "model_dump"):
+ try:
+ dumped = value.model_dump(exclude_unset=True)
+ except TypeError:
+ dumped = value.model_dump()
+ return {
+ "model": _identity_type_name(value),
+ "value": _normalize_identity_value(dumped),
+ }
+ if isinstance(value, Mapping):
+ return {
+ str(key): _normalize_identity_value(item)
+ for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
+ }
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
+ return [_normalize_identity_value(item) for item in value]
+
+ value_name = getattr(value, "name", None)
+ if isinstance(value_name, str):
+ return {"type": _identity_type_name(value), "name": value_name}
+ return {"type": _identity_type_name(value)}
+
+
+def _stable_identity_text(value: Any) -> str:
+ return json.dumps(
+ _normalize_identity_value(value),
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+
+
+def _tool_identity_signature(tool: Any) -> dict[str, Any]:
+ signature: dict[str, Any] = {
+ "type": _identity_type_name(tool),
+ "name": getattr(tool, "name", None),
+ }
+ namespace = get_function_tool_namespace(tool)
+ if namespace is not None:
+ signature["namespace"] = namespace
+ qualified_name = get_function_tool_qualified_name(tool)
+ if qualified_name is not None:
+ signature["qualified_name"] = qualified_name
+ if hasattr(tool, "environment"):
+ signature["environment"] = _normalize_identity_value(tool.environment)
+ if getattr(tool, "_is_agent_tool", False):
+ nested_agent = getattr(tool, "_agent_instance", None)
+ signature["agent_tool_target"] = getattr(nested_agent, "name", None)
+ return signature
+
+
+_THREADING_LOCK_TYPES = (type(threading.Lock()), type(threading.RLock()))
+
+
+def _is_capability_runtime_only_value(value: Any) -> bool:
+ return isinstance(
+ value,
+ (
+ BaseSandboxSession,
+ asyncio.Event,
+ asyncio.Lock,
+ asyncio.Semaphore,
+ asyncio.Condition,
+ threading.Event,
+ *_THREADING_LOCK_TYPES,
+ ),
+ )
+
+
+def _normalize_capability_identity_value(
+ value: Any,
+ *,
+ seen: set[int] | None = None,
+) -> Any:
+ if seen is None:
+ seen = set()
+
+ if value is None or isinstance(value, str | int | float | bool):
+ return value
+ if isinstance(value, Path):
+ return value.as_posix()
+ if isinstance(value, bytes | bytearray):
+ return {"type": "bytes", "length": len(value)}
+ if callable(value):
+ return {"callable": _callable_identity_name(value)}
+ if _is_capability_runtime_only_value(value):
+ return {"runtime_only": _identity_type_name(value)}
+ if isinstance(
+ value,
+ ApplyPatchTool | ComputerTool | FunctionTool | HostedMCPTool | LocalShellTool | ShellTool,
+ ):
+ return _tool_identity_signature(value)
+
+ object_id = id(value)
+ if object_id in seen:
+ return {"recursive": _identity_type_name(value)}
+
+ if dataclasses.is_dataclass(value):
+ seen.add(object_id)
+ try:
+ merged_fields = {
+ field.name: getattr(value, field.name) for field in dataclasses.fields(value)
+ }
+ if hasattr(value, "__dict__"):
+ for name, item in vars(value).items():
+ if name.startswith("_") or name in merged_fields:
+ continue
+ merged_fields[name] = item
+ return {
+ "dataclass": _identity_type_name(value),
+ "value": {
+ name: _normalize_capability_identity_value(
+ item,
+ seen=seen,
+ )
+ for name, item in sorted(merged_fields.items())
+ },
+ }
+ finally:
+ seen.remove(object_id)
+
+ if isinstance(value, Capability):
+ seen.add(object_id)
+ try:
+ merged_fields = {}
+ for name, field_info in value.__class__.model_fields.items():
+ if field_info.exclude or name.startswith("_") or name == "session":
+ continue
+ merged_fields[name] = getattr(value, name)
+ return {
+ "capability": _identity_type_name(value),
+ "value": {
+ name: _normalize_capability_identity_value(
+ item,
+ seen=seen,
+ )
+ for name, item in sorted(merged_fields.items())
+ },
+ }
+ finally:
+ seen.remove(object_id)
+
+ if hasattr(value, "model_dump"):
+ seen.add(object_id)
+ try:
+ try:
+ dumped = value.model_dump(mode="json", round_trip=True)
+ except TypeError:
+ dumped = value.model_dump(mode="json")
+ return {
+ "model": _identity_type_name(value),
+ "value": _normalize_capability_identity_value(dumped, seen=seen),
+ }
+ finally:
+ seen.remove(object_id)
+
+ if isinstance(value, Mapping):
+ seen.add(object_id)
+ try:
+ return {
+ str(key): _normalize_capability_identity_value(item, seen=seen)
+ for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
+ }
+ finally:
+ seen.remove(object_id)
+
+ if isinstance(value, set | frozenset):
+ seen.add(object_id)
+ try:
+ normalized_items = [
+ _normalize_capability_identity_value(item, seen=seen) for item in value
+ ]
+ return sorted(normalized_items, key=_stable_identity_text)
+ finally:
+ seen.remove(object_id)
+
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
+ seen.add(object_id)
+ try:
+ return [_normalize_capability_identity_value(item, seen=seen) for item in value]
+ finally:
+ seen.remove(object_id)
+
+ if hasattr(value, "__dict__"):
+ seen.add(object_id)
+ try:
+ return {
+ "object": _identity_type_name(value),
+ "value": {
+ name: _normalize_capability_identity_value(item, seen=seen)
+ for name, item in sorted(vars(value).items())
+ if not name.startswith("_")
+ },
+ }
+ finally:
+ seen.remove(object_id)
+
+ value_name = getattr(value, "name", None)
+ if isinstance(value_name, str):
+ return {"type": _identity_type_name(value), "name": value_name}
+ return {"type": _identity_type_name(value)}
+
+
+def _capability_identity_signature(capability: Any) -> dict[str, Any]:
+ return {
+ "type": _identity_type_name(capability),
+ "value": _normalize_capability_identity_value(capability),
+ }
+
+
+def _handoff_identity_signature(handoff_item: Agent[Any] | Handoff[Any, Any]) -> dict[str, Any]:
+ if isinstance(handoff_item, Handoff):
+ tool_name = getattr(handoff_item, "tool_name", None)
+ if not isinstance(tool_name, str):
+ tool_name = getattr(handoff_item, "name", None)
+ agent_name = getattr(handoff_item, "agent_name", None)
+ return {
+ "type": _identity_type_name(handoff_item),
+ "tool_name": tool_name,
+ "agent_name": agent_name if isinstance(agent_name, str) else None,
+ "input_filter": _normalize_identity_value(getattr(handoff_item, "input_filter", None)),
+ "nest_handoff_history": getattr(handoff_item, "nest_handoff_history", None),
+ }
+
+ return {
+ "type": _identity_type_name(handoff_item),
+ "agent_name": getattr(handoff_item, "name", None),
+ }
+
+
+def _agent_identity_signature(agent: Agent[Any]) -> str:
+ signature: dict[str, Any] = {
+ "agent_type": _identity_type_name(agent),
+ "handoff_description": getattr(agent, "handoff_description", None),
+ "instructions": _normalize_identity_value(getattr(agent, "instructions", None)),
+ "prompt": _normalize_identity_value(getattr(agent, "prompt", None)),
+ "model": _normalize_identity_value(getattr(agent, "model", None)),
+ "model_settings": _normalize_identity_value(getattr(agent, "model_settings", None)),
+ "mcp_config": _normalize_capability_identity_value(getattr(agent, "mcp_config", None)),
+ "hooks": _normalize_capability_identity_value(getattr(agent, "hooks", None)),
+ "input_guardrails": sorted(
+ _stable_identity_text(_normalize_capability_identity_value(guardrail))
+ for guardrail in getattr(agent, "input_guardrails", [])
+ ),
+ "output_guardrails": sorted(
+ _stable_identity_text(_normalize_capability_identity_value(guardrail))
+ for guardrail in getattr(agent, "output_guardrails", [])
+ ),
+ "output_type": _normalize_identity_value(getattr(agent, "output_type", None)),
+ "tool_use_behavior": _normalize_capability_identity_value(
+ getattr(agent, "tool_use_behavior", None)
+ ),
+ "reset_tool_choice": getattr(agent, "reset_tool_choice", None),
+ "tools": sorted(
+ _stable_identity_text(_tool_identity_signature(tool))
+ for tool in getattr(agent, "tools", [])
+ ),
+ "handoffs": sorted(
+ _stable_identity_text(_handoff_identity_signature(handoff_item))
+ for handoff_item in getattr(agent, "handoffs", [])
+ ),
+ "mcp_servers": sorted(
+ _stable_identity_text(server) for server in getattr(agent, "mcp_servers", [])
+ ),
+ }
+
+ default_manifest = getattr(agent, "default_manifest", None)
+ if default_manifest is not None:
+ signature["default_manifest"] = _normalize_capability_identity_value(default_manifest)
+
+ base_instructions = getattr(agent, "base_instructions", None)
+ if base_instructions is not None:
+ signature["base_instructions"] = _normalize_identity_value(base_instructions)
+
+ capabilities = getattr(agent, "capabilities", None)
+ if isinstance(capabilities, Sequence):
+ signature["capabilities"] = sorted(
+ _stable_identity_text(_capability_identity_signature(capability))
+ for capability in capabilities
+ )
+
+ return _stable_identity_text(signature)
+
+
+def _agent_identity_sort_key(
+ agent: Agent[Any],
+ *,
+ root_agent: Agent[Any],
+ original_index: int,
+) -> tuple[int, str, int]:
+ return (
+ 0 if agent is root_agent else 1,
+ _agent_identity_signature(agent),
+ original_index,
+ )
+
+
+def _build_agent_identity_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]:
+ """Build a stable identity map that preserves duplicate agent names."""
+ ordered_agents = list(_iter_agent_graph(initial_agent))
+ original_indices = {id(agent): index for index, agent in enumerate(ordered_agents)}
+ literal_names = {agent.name for agent in ordered_agents}
+ agents_by_name: dict[str, list[Agent[Any]]] = {}
+ for agent in ordered_agents:
+ agents_by_name.setdefault(agent.name, []).append(agent)
+
+ agent_identity_map: dict[str, Agent[Any]] = {}
+ used_identities: set[str] = set()
+ processed_names: set[str] = set()
+
+ for agent in ordered_agents:
+ agent_name = agent.name
+ if agent_name in processed_names:
+ continue
+ processed_names.add(agent_name)
+
+ group = agents_by_name[agent_name]
+ sorted_group = sorted(
+ group,
+ key=lambda candidate: _agent_identity_sort_key(
+ candidate,
+ root_agent=initial_agent,
+ original_index=original_indices[id(candidate)],
+ ),
+ )
+
+ base_agent = sorted_group[0]
+ used_identities.add(agent_name)
+ agent_identity_map[agent_name] = base_agent
+
+ next_index = 2
+ for duplicate_agent in sorted_group[1:]:
+ candidate = f"{agent_name}#{next_index}"
+ while candidate in used_identities or candidate in literal_names:
+ next_index += 1
+ candidate = f"{agent_name}#{next_index}"
+ used_identities.add(candidate)
+ agent_identity_map[candidate] = duplicate_agent
+ next_index += 1
+
+ return agent_identity_map
+
+
+def _build_agent_identity_keys_by_id(initial_agent: Agent[Any]) -> dict[int, str]:
+ """Build stable identity keys for the reachable agent graph."""
+ return {
+ id(agent): identity for identity, agent in _build_agent_identity_map(initial_agent).items()
+ }
+
+
+def _build_agent_map(initial_agent: Agent[Any]) -> dict[str, Agent[Any]]:
+ """Build a map of agent names to agents by traversing handoffs.
+
+ Args:
+ initial_agent: The starting agent.
+
+ Returns:
+ Dictionary mapping agent names to agent instances.
+ """
+ agent_map: dict[str, Agent[Any]] = {}
+ for agent in _iter_agent_graph(initial_agent):
+ agent_map.setdefault(agent.name, agent)
+
return agent_map
@@ -2403,13 +3052,13 @@ def _deserialize_model_responses(responses_data: list[dict[str, Any]]) -> list[M
for resp_data in responses_data:
usage = deserialize_usage(resp_data.get("usage", {}))
- normalized_output = [
- dict(item) if isinstance(item, Mapping) else item for item in resp_data["output"]
+ output: list[Any] = [
+ _deserialize_message_output_item(item)
+ if isinstance(item, Mapping) and item.get("type") == "message"
+ else item
+ for item in resp_data["output"]
]
- output_adapter: TypeAdapter[Any] = TypeAdapter(list[Any])
- output = output_adapter.validate_python(normalized_output)
-
response_id = resp_data.get("response_id")
request_id = resp_data.get("request_id")
@@ -2426,7 +3075,10 @@ def _deserialize_model_responses(responses_data: list[dict[str, Any]]) -> list[M
def _deserialize_items(
- items_data: list[dict[str, Any]], agent_map: dict[str, Agent[Any]]
+ items_data: list[dict[str, Any]],
+ agent_map: dict[str, Agent[Any]],
+ *,
+ agent_identity_map: Mapping[str, Agent[Any]] | None = None,
) -> list[RunItem]:
"""Deserialize run items from JSON data.
@@ -2456,7 +3108,11 @@ def _deserialize_items(
elif isinstance(raw_agent, str):
candidate_name = raw_agent
- agent_candidate = _resolve_agent_from_data(raw_agent, agent_map)
+ agent_candidate = _resolve_agent_from_data(
+ raw_agent,
+ agent_map,
+ agent_identity_map,
+ )
if agent_candidate:
return agent_candidate, agent_candidate.name
@@ -2483,7 +3139,7 @@ def _deserialize_items(
try:
if item_type == "message_output_item":
- raw_item_msg = ResponseOutputMessage(**normalized_raw_item)
+ raw_item_msg = _deserialize_message_output_item(normalized_raw_item)
result.append(MessageOutputItem(agent=agent, raw_item=raw_item_msg))
elif item_type == "tool_search_call_item":
@@ -2537,8 +3193,16 @@ def _deserialize_items(
result.append(HandoffCallItem(agent=agent, raw_item=raw_item_handoff))
elif item_type == "handoff_output_item":
- source_agent = _resolve_agent_from_data(item_data.get("source_agent"), agent_map)
- target_agent = _resolve_agent_from_data(item_data.get("target_agent"), agent_map)
+ source_agent = _resolve_agent_from_data(
+ item_data.get("source_agent"),
+ agent_map,
+ agent_identity_map,
+ )
+ target_agent = _resolve_agent_from_data(
+ item_data.get("target_agent"),
+ agent_map,
+ agent_identity_map,
+ )
# If we cannot resolve both agents, skip this item gracefully
if not source_agent or not target_agent:
@@ -2601,12 +3265,15 @@ def _deserialize_items(
approval_item = _deserialize_tool_approval_item(
item_data,
agent_map=agent_map,
+ agent_identity_map=agent_identity_map,
fallback_agent=agent,
pre_normalized_raw_item=normalized_raw_item,
)
if approval_item is not None:
result.append(approval_item)
+ except UserError:
+ raise
except Exception as e:
logger.warning(f"Failed to deserialize item of type {item_type}: {e}")
continue
diff --git a/src/agents/sandbox/__init__.py b/src/agents/sandbox/__init__.py
new file mode 100644
index 00000000..75669900
--- /dev/null
+++ b/src/agents/sandbox/__init__.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from ..run_config import SandboxConcurrencyLimits, SandboxRunConfig
+from .capabilities import Capability
+from .config import MemoryGenerateConfig, MemoryLayoutConfig, MemoryReadConfig
+from .entries import Dir, LocalFile
+from .errors import (
+ ErrorCode,
+ ExecTimeoutError,
+ ExecTransportError,
+ ExposedPortUnavailableError,
+ SandboxError,
+ WorkspaceArchiveReadError,
+ WorkspaceArchiveWriteError,
+ WorkspaceReadNotFoundError,
+ WorkspaceWriteTypeError,
+)
+from .manifest import Manifest
+from .sandbox_agent import SandboxAgent
+from .snapshot import (
+ LocalSnapshot,
+ LocalSnapshotSpec,
+ RemoteSnapshot,
+ RemoteSnapshotSpec,
+ SnapshotSpec,
+ resolve_snapshot,
+)
+from .types import ExecResult, ExposedPortEndpoint, FileMode, Group, Permissions, User
+
+__all__ = [
+ "Capability",
+ "Dir",
+ "ErrorCode",
+ "ExecResult",
+ "ExposedPortEndpoint",
+ "ExposedPortUnavailableError",
+ "ExecTimeoutError",
+ "ExecTransportError",
+ "FileMode",
+ "Group",
+ "LocalFile",
+ "LocalSnapshot",
+ "LocalSnapshotSpec",
+ "Manifest",
+ "MemoryLayoutConfig",
+ "MemoryReadConfig",
+ "MemoryGenerateConfig",
+ "RemoteSnapshot",
+ "RemoteSnapshotSpec",
+ "Permissions",
+ "SandboxAgent",
+ "SandboxConcurrencyLimits",
+ "SandboxError",
+ "SandboxRunConfig",
+ "SnapshotSpec",
+ "WorkspaceArchiveReadError",
+ "WorkspaceArchiveWriteError",
+ "WorkspaceReadNotFoundError",
+ "WorkspaceWriteTypeError",
+ "User",
+ "resolve_snapshot",
+]
diff --git a/src/agents/sandbox/apply_patch.py b/src/agents/sandbox/apply_patch.py
new file mode 100644
index 00000000..d85598f4
--- /dev/null
+++ b/src/agents/sandbox/apply_patch.py
@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+import io
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, runtime_checkable
+
+from ..apply_diff import ApplyDiffMode, apply_diff
+from ..editor import ApplyPatchOperation, ApplyPatchOperationType, ApplyPatchResult
+from .errors import (
+ ApplyPatchDecodeError,
+ ApplyPatchDiffError,
+ ApplyPatchFileNotFoundError,
+ ApplyPatchPathError,
+ InvalidManifestPathError,
+ WorkspaceReadNotFoundError,
+)
+
+if TYPE_CHECKING:
+ from .session.base_sandbox_session import BaseSandboxSession
+ from .types import User
+
+
+@runtime_checkable
+class PatchFormat(Protocol):
+ @staticmethod
+ def apply_diff(input: str, diff: str, mode: ApplyDiffMode = "default") -> str: ...
+
+
+class V4AFormat:
+ @staticmethod
+ def apply_diff(input: str, diff: str, mode: ApplyDiffMode = "default") -> str:
+ return apply_diff(input, diff, mode=mode)
+
+
+class WorkspaceEditor:
+ def __init__(
+ self,
+ session: BaseSandboxSession,
+ *,
+ user: str | User | None = None,
+ ) -> None:
+ self._session = session
+ self._user = user
+
+ async def apply_patch(
+ self,
+ operations: ApplyPatchOperation
+ | dict[str, object]
+ | list[ApplyPatchOperation | dict[str, object]],
+ *,
+ patch_format: PatchFormat | Literal["v4a"] = "v4a",
+ ) -> str:
+ format_impl = _resolve_patch_format(patch_format)
+ for operation in _coerce_operations(operations):
+ await self.apply_operation(operation, patch_format=format_impl)
+ return "Done!"
+
+ async def apply_operation(
+ self,
+ operation: ApplyPatchOperation,
+ *,
+ patch_format: PatchFormat | Literal["v4a"] = "v4a",
+ ) -> ApplyPatchResult:
+ format_impl = _resolve_patch_format(patch_format)
+ relative_path = self._validate_path(operation.path)
+ destination = self._session.normalize_path(relative_path)
+ display_path = relative_path.as_posix()
+
+ if operation.type == "delete_file":
+ await self._ensure_exists(destination, display_path=display_path)
+ await self._session.rm(destination, user=self._user)
+ return ApplyPatchResult(output=f"Deleted {display_path}")
+
+ if operation.diff is None:
+ raise ApplyPatchDiffError(
+ message=(
+ f"Missing diff for operation type {operation.type} on path {operation.path}"
+ ),
+ path=operation.path,
+ )
+
+ if operation.type == "update_file":
+ original_text = await self._read_text(destination, op_path=operation.path)
+ try:
+ updated_text = format_impl.apply_diff(original_text, operation.diff, mode="default")
+ except ValueError as exc:
+ raise ApplyPatchDiffError(
+ message=str(exc),
+ path=operation.path,
+ cause=exc,
+ ) from exc
+ if operation.move_to is None:
+ await self._write_text(destination, updated_text)
+ return ApplyPatchResult(output=f"Updated {display_path}")
+
+ moved_relative_path = self._validate_path(operation.move_to)
+ moved_destination = self._session.normalize_path(moved_relative_path)
+ await self._write_text(moved_destination, updated_text)
+ if moved_destination != destination:
+ await self._session.rm(destination)
+ moved_display_path = moved_relative_path.as_posix()
+ return ApplyPatchResult(
+ output=f"Updated {display_path}\nMoved {display_path} to {moved_display_path}"
+ )
+
+ if operation.type == "create_file":
+ try:
+ created_text = format_impl.apply_diff("", operation.diff, mode="create")
+ except ValueError as exc:
+ raise ApplyPatchDiffError(
+ message=str(exc),
+ path=operation.path,
+ cause=exc,
+ ) from exc
+ await self._write_text(destination, created_text)
+ return ApplyPatchResult(output=f"Created {display_path}")
+
+ raise ApplyPatchDiffError(
+ message=f"Unknown operation type: {operation.type}",
+ path=operation.path,
+ )
+
+ def _validate_path(self, path: str | Path) -> Path:
+ if isinstance(path, str):
+ if not path.strip():
+ raise ApplyPatchPathError(path=path, reason="empty")
+ normalized_path = Path(path)
+ else:
+ normalized_path = path
+
+ try:
+ return self._session._workspace_path_policy().relative_path(normalized_path)
+ except InvalidManifestPathError as exc:
+ raise ApplyPatchPathError(
+ path=normalized_path,
+ reason="escape_root",
+ cause=exc,
+ ) from exc
+
+ async def _ensure_exists(self, destination: Path, *, display_path: str) -> None:
+ try:
+ handle = await self._session.read(destination, user=self._user)
+ except (FileNotFoundError, WorkspaceReadNotFoundError) as exc:
+ raise ApplyPatchFileNotFoundError(path=Path(display_path), cause=exc) from exc
+ else:
+ handle.close()
+
+ async def _read_text(self, destination: Path, *, op_path: str) -> str:
+ try:
+ handle = await self._session.read(destination, user=self._user)
+ except (FileNotFoundError, WorkspaceReadNotFoundError) as exc:
+ raise ApplyPatchFileNotFoundError(path=Path(op_path), cause=exc) from exc
+
+ try:
+ payload = handle.read()
+ finally:
+ handle.close()
+
+ if isinstance(payload, str):
+ return payload
+ if isinstance(payload, bytes | bytearray):
+ try:
+ return bytes(payload).decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise ApplyPatchDecodeError(path=destination, cause=exc) from exc
+ raise ApplyPatchDiffError(
+ message=f"apply_patch read() returned non-text content: {type(payload).__name__}",
+ path=op_path,
+ )
+
+ async def _write_text(self, destination: Path, text: str) -> None:
+ await self._session.mkdir(destination.parent, parents=True, user=self._user)
+ await self._session.write(
+ destination,
+ io.BytesIO(text.encode("utf-8")),
+ user=self._user,
+ )
+
+
+def _coerce_operations(
+ operations: ApplyPatchOperation
+ | dict[str, object]
+ | list[ApplyPatchOperation | dict[str, object]],
+) -> list[ApplyPatchOperation]:
+ if isinstance(operations, ApplyPatchOperation):
+ return [operations]
+ if isinstance(operations, dict):
+ return [_coerce_operation_mapping(operations)]
+ if isinstance(operations, list):
+ coerced: list[ApplyPatchOperation] = []
+ for operation in operations:
+ if isinstance(operation, ApplyPatchOperation):
+ coerced.append(operation)
+ elif isinstance(operation, dict):
+ coerced.append(_coerce_operation_mapping(operation))
+ else:
+ raise ApplyPatchDiffError(
+ message=f"Invalid apply_patch operation type: {type(operation).__name__}"
+ )
+ return coerced
+ raise ApplyPatchDiffError(
+ message=f"Invalid apply_patch operations payload: {type(operations).__name__}"
+ )
+
+
+def _coerce_operation_mapping(operation: dict[str, object]) -> ApplyPatchOperation:
+ raw_type = operation.get("type")
+ raw_path = operation.get("path")
+ raw_diff = operation.get("diff")
+ raw_ctx_wrapper = operation.get("ctx_wrapper")
+
+ if raw_type not in {"create_file", "update_file", "delete_file"}:
+ raise ApplyPatchDiffError(
+ message=f"Invalid apply_patch operation type: {type(raw_type).__name__}"
+ )
+ if not isinstance(raw_path, str):
+ raise ApplyPatchDiffError(
+ message=f"Invalid apply_patch path type: {type(raw_path).__name__}"
+ )
+ if raw_diff is not None and not isinstance(raw_diff, str):
+ raise ApplyPatchDiffError(
+ message=f"Invalid apply_patch diff type: {type(raw_diff).__name__}"
+ )
+ return ApplyPatchOperation(
+ type=cast(ApplyPatchOperationType, raw_type),
+ path=raw_path,
+ diff=raw_diff,
+ ctx_wrapper=cast(Any, raw_ctx_wrapper),
+ )
+
+
+def _resolve_patch_format(
+ patch_format: PatchFormat | Literal["v4a"],
+) -> PatchFormat:
+ if patch_format == "v4a":
+ return V4AFormat
+ if isinstance(patch_format, PatchFormat):
+ return patch_format
+ raise ApplyPatchDiffError(message=f"Unsupported patch format: {patch_format!r}")
+
+
+__all__ = ["PatchFormat", "V4AFormat", "WorkspaceEditor"]
diff --git a/src/agents/sandbox/capabilities/__init__.py b/src/agents/sandbox/capabilities/__init__.py
new file mode 100644
index 00000000..d02aa1ed
--- /dev/null
+++ b/src/agents/sandbox/capabilities/__init__.py
@@ -0,0 +1,33 @@
+from .capabilities import Capabilities
+from .capability import Capability
+from .compaction import (
+ Compaction,
+ CompactionModelInfo,
+ CompactionPolicy,
+ DynamicCompactionPolicy,
+ StaticCompactionPolicy,
+)
+from .filesystem import Filesystem, FilesystemToolSet
+from .memory import Memory
+from .shell import Shell, ShellToolSet
+from .skills import LazySkillSource, LocalDirLazySkillSource, Skill, SkillMetadata, Skills
+
+__all__ = [
+ "Capability",
+ "Capabilities",
+ "Compaction",
+ "CompactionModelInfo",
+ "CompactionPolicy",
+ "DynamicCompactionPolicy",
+ "FilesystemToolSet",
+ "LazySkillSource",
+ "LocalDirLazySkillSource",
+ "Memory",
+ "Shell",
+ "ShellToolSet",
+ "Skill",
+ "SkillMetadata",
+ "Skills",
+ "StaticCompactionPolicy",
+ "Filesystem",
+]
diff --git a/src/agents/sandbox/capabilities/capabilities.py b/src/agents/sandbox/capabilities/capabilities.py
new file mode 100644
index 00000000..9e96b9b2
--- /dev/null
+++ b/src/agents/sandbox/capabilities/capabilities.py
@@ -0,0 +1,10 @@
+from .capability import Capability
+from .compaction import Compaction
+from .filesystem import Filesystem
+from .shell import Shell
+
+
+class Capabilities:
+ @classmethod
+ def default(cls) -> list[Capability]:
+ return [Filesystem(), Shell(), Compaction()]
diff --git a/src/agents/sandbox/capabilities/capability.py b/src/agents/sandbox/capabilities/capability.py
new file mode 100644
index 00000000..c547227f
--- /dev/null
+++ b/src/agents/sandbox/capabilities/capability.py
@@ -0,0 +1,99 @@
+import asyncio
+import copy
+import threading
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from ...items import TResponseInputItem
+from ...tool import Tool
+from ..manifest import Manifest
+from ..session.base_sandbox_session import BaseSandboxSession
+from ..types import User
+
+
+class Capability(BaseModel):
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+ type: str
+ session: BaseSandboxSession | None = Field(default=None, exclude=True)
+ run_as: User | None = Field(default=None, exclude=True)
+
+ def clone(self) -> "Capability":
+ """Return a per-run copy of this capability."""
+ cloned = self.model_copy(deep=False)
+ for name, value in self.__dict__.items():
+ cloned.__dict__[name] = _clone_capability_value(value)
+ return cloned
+
+ def bind(self, session: BaseSandboxSession) -> None:
+ """Bind a live session to this plugin (default no-op)."""
+ self.session = session
+
+ def bind_run_as(self, user: User | None) -> None:
+ """Bind the sandbox user identity for model-facing operations."""
+ self.run_as = user
+
+ def required_capability_types(self) -> set[str]:
+ """Return capability types that must be present alongside this capability."""
+ return set()
+
+ def tools(self) -> list[Tool]:
+ return []
+
+ def process_manifest(self, manifest: Manifest) -> Manifest:
+ return manifest
+
+ async def instructions(self, manifest: Manifest) -> str | None:
+ """Return a deterministic instruction fragment appended during run preparation."""
+ _ = manifest
+ return None
+
+ def sampling_params(self, sampling_params: dict[str, Any]) -> dict[str, Any]:
+ """Return additional model request parameters needed for this capability."""
+ _ = sampling_params
+ return {}
+
+ def process_context(self, context: list[TResponseInputItem]) -> list[TResponseInputItem]:
+ """Transform the model input context before sampling."""
+ return context
+
+
+def _clone_capability_value(value: Any) -> Any:
+ if getattr(type(value), "__module__", "").startswith("agents.tool"):
+ return value
+ if isinstance(
+ value,
+ BaseSandboxSession
+ | asyncio.Event
+ | asyncio.Lock
+ | asyncio.Semaphore
+ | asyncio.Condition
+ | threading.Event
+ | type(threading.Lock())
+ | type(threading.RLock()),
+ ):
+ return value
+ if isinstance(value, list):
+ return [_clone_capability_value(item) for item in value]
+ if isinstance(value, dict):
+ return {
+ _clone_capability_value(key): _clone_capability_value(item)
+ for key, item in value.items()
+ }
+ if isinstance(value, set):
+ return {_clone_capability_value(item) for item in value}
+ if isinstance(value, tuple):
+ return tuple(_clone_capability_value(item) for item in value)
+ if isinstance(value, bytearray):
+ return bytearray(value)
+ if hasattr(value, "__dict__"):
+ cloned = copy.copy(value)
+ for name, nested in value.__dict__.items():
+ setattr(cloned, name, _clone_capability_value(nested))
+ return cloned
+ try:
+ return copy.deepcopy(value)
+ except Exception:
+ return value
+ return value
diff --git a/src/agents/sandbox/capabilities/compaction.py b/src/agents/sandbox/capabilities/compaction.py
new file mode 100644
index 00000000..38d79355
--- /dev/null
+++ b/src/agents/sandbox/capabilities/compaction.py
@@ -0,0 +1,184 @@
+from __future__ import annotations
+
+import abc
+from collections.abc import Mapping
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field, field_serializer, field_validator
+
+from ...items import TResponseInputItem
+from .capability import Capability
+
+_DEFAULT_COMPACT_THRESHOLD = 240_000
+
+
+class CompactionModelInfo(BaseModel):
+ context_window: int
+
+ @classmethod
+ def for_model(cls, model: str) -> CompactionModelInfo:
+ normalized_model = model.removeprefix("openai/")
+
+ if normalized_model in (
+ "gpt-5.4",
+ "gpt-5.4-2026-03-05",
+ "gpt-5.4-pro",
+ "gpt-5.4-pro-2026-03-05",
+ "gpt-4.1",
+ "gpt-4.1-2025-04-14",
+ "gpt-4.1-mini",
+ "gpt-4.1-mini-2025-04-14",
+ "gpt-4.1-nano",
+ "gpt-4.1-nano-2025-04-14",
+ ):
+ return cls(context_window=1_047_576)
+ if normalized_model in (
+ "gpt-5",
+ "gpt-5-2025-08-07",
+ "gpt-5-codex",
+ "gpt-5-mini",
+ "gpt-5-mini-2025-08-07",
+ "gpt-5-nano",
+ "gpt-5-nano-2025-08-07",
+ "gpt-5-pro",
+ "gpt-5-pro-2025-10-06",
+ "gpt-5.1",
+ "gpt-5.1-2025-11-13",
+ "gpt-5.1-codex",
+ "gpt-5.1-codex-max",
+ "gpt-5.1-codex-mini",
+ "gpt-5.2",
+ "gpt-5.2-2025-12-11",
+ "gpt-5.2-codex",
+ "gpt-5.2-pro",
+ "gpt-5.2-pro-2025-12-11",
+ "gpt-5.3-codex",
+ "gpt-5.4-mini",
+ "gpt-5.4-mini-2026-03-17",
+ "gpt-5.4-nano",
+ "gpt-5.4-nano-2026-03-17",
+ ):
+ return cls(context_window=400_000)
+ if normalized_model in (
+ "codex-mini-latest",
+ "o1",
+ "o1-2024-12-17",
+ "o1-pro",
+ "o1-pro-2025-03-19",
+ "o3",
+ "o3-2025-04-16",
+ "o3-deep-research",
+ "o3-deep-research-2025-06-26",
+ "o3-mini",
+ "o3-mini-2025-01-31",
+ "o3-pro",
+ "o3-pro-2025-06-10",
+ "o4-mini",
+ "o4-mini-2025-04-16",
+ "o4-mini-deep-research",
+ "o4-mini-deep-research-2025-06-26",
+ ):
+ return cls(context_window=200_000)
+ if normalized_model in (
+ "gpt-4o",
+ "gpt-4o-2024-05-13",
+ "gpt-4o-2024-08-06",
+ "gpt-4o-2024-11-20",
+ "gpt-4o-mini",
+ "gpt-4o-mini-2024-07-18",
+ "gpt-5-chat-latest",
+ "gpt-5.1-chat-latest",
+ "gpt-5.2-chat-latest",
+ "gpt-5.3-chat-latest",
+ ):
+ return cls(context_window=128_000)
+
+ raise ValueError(f"Unknown context window for model: {model!r}")
+
+
+class CompactionPolicy(BaseModel, abc.ABC):
+ type: str
+
+ @abc.abstractmethod
+ def compaction_threshold(self, sampling_params: dict[str, Any]) -> int: ...
+
+
+class StaticCompactionPolicy(CompactionPolicy):
+ type: Literal["static"] = "static"
+ threshold: int = Field(default=_DEFAULT_COMPACT_THRESHOLD)
+
+ def compaction_threshold(self, sampling_params: dict[str, Any]) -> int:
+ _ = sampling_params
+ return self.threshold
+
+
+class DynamicCompactionPolicy(CompactionPolicy):
+ type: Literal["dynamic"] = "dynamic"
+ model_info: CompactionModelInfo
+ threshold: float = Field(ge=0, le=1, default=0.9)
+
+ def compaction_threshold(self, sampling_params: dict[str, Any]) -> int:
+ _ = sampling_params
+ return int(self.model_info.context_window * self.threshold)
+
+
+class Compaction(Capability):
+ type: Literal["compaction"] = "compaction"
+ policy: CompactionPolicy | None = Field(default=None)
+
+ @field_validator("policy", mode="before")
+ @classmethod
+ def _validate_policy(cls, value: object) -> object | None:
+ if value is None:
+ return None
+ if isinstance(value, CompactionPolicy):
+ return value
+ if isinstance(value, Mapping):
+ policy_type = value.get("type")
+ if policy_type == "static":
+ return StaticCompactionPolicy.model_validate(dict(value))
+ if policy_type == "dynamic":
+ return DynamicCompactionPolicy.model_validate(dict(value))
+ raise ValueError(f"Unsupported compaction policy type: {policy_type!r}")
+ return value
+
+ @field_serializer("policy", when_used="always", return_type=dict[str, Any])
+ def _serialize_policy(self, policy: CompactionPolicy | None) -> dict[str, Any] | None:
+ if policy is None:
+ return None
+ return policy.model_dump()
+
+ def sampling_params(self, sampling_params: dict[str, Any]) -> dict[str, Any]:
+ policy = self.policy
+ if policy is None:
+ model = sampling_params.get("model")
+ if isinstance(model, str) and model:
+ policy = DynamicCompactionPolicy(model_info=CompactionModelInfo.for_model(model))
+ else:
+ policy = StaticCompactionPolicy()
+
+ return {
+ "context_management": [
+ {
+ "type": "compaction",
+ "compact_threshold": policy.compaction_threshold(sampling_params),
+ }
+ ]
+ }
+
+ def process_context(self, context: list[TResponseInputItem]) -> list[TResponseInputItem]:
+ """When a compaction item is received, truncate the context before it."""
+ last_compaction_index: int | None = None
+ for index in range(len(context) - 1, -1, -1):
+ item = context[index]
+ item_type = (
+ item.get("type") if isinstance(item, Mapping) else getattr(item, "type", None)
+ )
+ if item_type == "compaction":
+ last_compaction_index = index
+ break
+
+ if last_compaction_index is not None:
+ return context[last_compaction_index:]
+
+ return context
diff --git a/src/agents/sandbox/capabilities/filesystem.py b/src/agents/sandbox/capabilities/filesystem.py
new file mode 100644
index 00000000..aa023765
--- /dev/null
+++ b/src/agents/sandbox/capabilities/filesystem.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Literal
+
+from pydantic import Field
+
+from ...tool import Tool
+from .capability import Capability
+from .tools import SandboxApplyPatchTool, ViewImageTool
+
+
+@dataclass
+class FilesystemToolSet:
+ """Mutable bundle of tools exposed by the filesystem capability."""
+
+ view_image: ViewImageTool
+ apply_patch: SandboxApplyPatchTool
+
+
+FilesystemToolConfigurator = Callable[[FilesystemToolSet], None]
+
+
+class Filesystem(Capability):
+ type: Literal["filesystem"] = "filesystem"
+ configure_tools: FilesystemToolConfigurator | None = Field(default=None, exclude=True)
+ """Optional callback that can customize or replace bundled filesystem tools."""
+
+ def tools(self) -> list[Tool]:
+ if self.session is None:
+ raise ValueError("Filesystem capability is not bound to a SandboxSession")
+
+ toolset = FilesystemToolSet(
+ view_image=ViewImageTool(session=self.session, user=self.run_as),
+ apply_patch=SandboxApplyPatchTool(session=self.session, user=self.run_as),
+ )
+ if self.configure_tools is not None:
+ self.configure_tools(toolset)
+
+ return [toolset.view_image, toolset.apply_patch]
diff --git a/src/agents/sandbox/capabilities/memory.py b/src/agents/sandbox/capabilities/memory.py
new file mode 100644
index 00000000..ed9e4824
--- /dev/null
+++ b/src/agents/sandbox/capabilities/memory.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Literal, cast
+
+from pydantic import Field
+
+from ..config import MemoryGenerateConfig, MemoryLayoutConfig, MemoryReadConfig
+from ..errors import WorkspaceReadNotFoundError
+from ..manifest import Manifest
+from ..memory.prompts import render_memory_read_prompt
+from ..util.token_truncation import TruncationPolicy, truncate_text
+from .capability import Capability
+
+_MEMORY_SUMMARY_MAX_TOKENS = 15_000
+
+
+class Memory(Capability):
+ """Read and generate sandbox memory artifacts for an agent.
+
+ `Shell` is required for memory reads. `Filesystem` is required when live updates are enabled.
+ """
+
+ type: Literal["memory"] = "memory"
+ layout: MemoryLayoutConfig = Field(default_factory=MemoryLayoutConfig)
+ """Filesystem layout used for rollout and memory files."""
+ read: MemoryReadConfig | None = Field(default_factory=MemoryReadConfig)
+ """Read-side configuration. Set to `None` to disable memory reads."""
+ generate: MemoryGenerateConfig | None = Field(default_factory=MemoryGenerateConfig)
+ """Generation configuration. Set to `None` to disable background memory generation."""
+
+ def clone(self) -> Memory:
+ """Return a per-run copy without deep-copying stateful memory model objects."""
+ return self.model_copy(deep=False, update={"session": None})
+
+ def model_post_init(self, context: object, /) -> None:
+ _ = context
+ if self.read is None and self.generate is None:
+ raise ValueError("Memory requires at least one of `read` or `generate`.")
+ _validate_relative_path(name="layout.memories_dir", path=Path(self.layout.memories_dir))
+ _validate_relative_path(name="layout.sessions_dir", path=Path(self.layout.sessions_dir))
+
+ def required_capability_types(self) -> set[str]:
+ if self.read is None:
+ return set()
+ if self.read.live_update:
+ return {"filesystem", "shell"}
+ return {"shell"}
+
+ async def instructions(self, manifest: Manifest) -> str | None:
+ _ = manifest
+ if self.read is None:
+ return None
+ if self.session is None:
+ raise ValueError("Memory capability is not bound to a SandboxSession")
+
+ memory_summary_path = Path(self.layout.memories_dir) / "memory_summary.md"
+ try:
+ handle = await self.session.read(memory_summary_path, user=self.run_as)
+ except WorkspaceReadNotFoundError:
+ return None
+
+ try:
+ payload = handle.read()
+ finally:
+ handle.close()
+
+ memory_summary = truncate_text(
+ cast(bytes, payload).decode("utf-8", errors="replace").strip(),
+ TruncationPolicy.tokens(_MEMORY_SUMMARY_MAX_TOKENS),
+ )
+ if not memory_summary:
+ return None
+
+ return render_memory_read_prompt(
+ memory_dir=self.layout.memories_dir,
+ memory_summary=memory_summary,
+ live_update=self.read.live_update,
+ )
+
+
+def _validate_relative_path(*, name: str, path: Path) -> None:
+ if path.is_absolute():
+ raise ValueError(f"{name} must be relative to the sandbox workspace root, got: {path}")
+ if ".." in path.parts:
+ raise ValueError(f"{name} must not escape root, got: {path}")
+ if path.parts in [(), (".",)]:
+ raise ValueError(f"{name} must be non-empty")
diff --git a/src/agents/sandbox/capabilities/shell.py b/src/agents/sandbox/capabilities/shell.py
new file mode 100644
index 00000000..44624f6f
--- /dev/null
+++ b/src/agents/sandbox/capabilities/shell.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from textwrap import dedent
+from typing import Literal
+
+from pydantic import Field
+
+from ...tool import Tool
+from ..manifest import Manifest
+from .capability import Capability
+from .tools import ExecCommandTool, WriteStdinTool
+
+_SHELL_INSTRUCTIONS = dedent(
+ """
+ When using the shell:
+ - Use `exec_command` for shell execution.
+ - If available, use `write_stdin` to interact with or poll running sessions.
+ - To interrupt a long-running process via `write_stdin`, start it with `tty=true` and send \
+Ctrl-C (`\\u0003`).
+ - Prefer `rg` and `rg --files` for text/file discovery when available.
+ - Avoid using Python scripts just to print large file chunks.
+ """
+).strip()
+
+
+@dataclass
+class ShellToolSet:
+ """Mutable bundle of tools exposed by the shell capability."""
+
+ exec_command: ExecCommandTool
+ write_stdin: WriteStdinTool | None
+
+
+ShellToolConfigurator = Callable[[ShellToolSet], None]
+
+
+class Shell(Capability):
+ type: Literal["shell"] = "shell"
+ configure_tools: ShellToolConfigurator | None = Field(default=None, exclude=True)
+ """Optional callback that can customize or replace bundled shell tools."""
+
+ def tools(self) -> list[Tool]:
+ if self.session is None:
+ raise ValueError("Shell capability is not bound to a SandboxSession")
+ toolset = ShellToolSet(
+ exec_command=ExecCommandTool(session=self.session, user=self.run_as),
+ write_stdin=WriteStdinTool(session=self.session)
+ if self.session.supports_pty()
+ else None,
+ )
+ if self.configure_tools is not None:
+ self.configure_tools(toolset)
+ tools: list[Tool] = [toolset.exec_command]
+ if toolset.write_stdin is not None:
+ tools.append(toolset.write_stdin)
+ return tools
+
+ async def instructions(self, manifest: Manifest) -> str | None:
+ _ = manifest
+ return _SHELL_INSTRUCTIONS
diff --git a/src/agents/sandbox/capabilities/skills.py b/src/agents/sandbox/capabilities/skills.py
new file mode 100644
index 00000000..b3688958
--- /dev/null
+++ b/src/agents/sandbox/capabilities/skills.py
@@ -0,0 +1,733 @@
+from __future__ import annotations
+
+import abc
+import io
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
+
+from ...tool import FunctionTool, Tool
+from ..entries import BaseEntry, Dir, File, LocalDir, LocalFile
+from ..errors import SkillsConfigError
+from ..manifest import Manifest
+from ..session.base_sandbox_session import BaseSandboxSession
+from ..types import User
+from .capability import Capability
+
+_SKILLS_SECTION_INTRO = (
+ "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. "
+ "Below is the list of skills that can be used. Each entry includes a name, description, "
+ "and file path so you can open the source for full instructions when using a specific skill."
+)
+
+_HOW_TO_USE_SKILLS_SECTION = "\n".join(
+ [
+ "### How to use skills",
+ "- Discovery: The list above is the skills available in this session "
+ "(name + description + file path). Skill bodies live on disk at the listed paths.",
+ "- Trigger rules: If the user names a skill (with `$SkillName` or plain text) "
+ "OR the task clearly matches a skill's description shown above, you must use that "
+ "skill for that turn. Multiple mentions mean use them all. Do not carry skills "
+ "across turns unless re-mentioned.",
+ "- Missing/blocked: If a named skill isn't in the list or the path can't be read, "
+ "say so briefly and continue with the best fallback.",
+ "- How to use a skill (progressive disclosure):",
+ " 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to "
+ "follow the workflow.",
+ " 2) If `SKILL.md` points to extra folders such as `references/`, load only the "
+ "specific files needed for the request; don't bulk-load everything.",
+ " 3) If `scripts/` exist, prefer running or patching them instead of retyping "
+ "large code blocks.",
+ " 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.",
+ "- Coordination and sequencing:",
+ " - If multiple skills apply, choose the minimal set that covers the request "
+ "and state the order you'll use them.",
+ " - Announce which skill(s) you're using and why (one short line). "
+ "If you skip an obvious skill, say why.",
+ "- Context hygiene:",
+ " - Keep context small: summarize long sections instead of pasting them; "
+ "only load extra files when needed.",
+ " - Avoid deep reference-chasing: prefer opening only files directly linked "
+ "from `SKILL.md` unless you're blocked.",
+ " - When variants exist (frameworks, providers, domains), pick only the relevant "
+ "reference file(s) and note that choice.",
+ "- Safety and fallback: If a skill can't be applied cleanly (missing files, "
+ "unclear instructions), state the issue, pick the next-best approach, and continue.",
+ ]
+)
+
+_HOW_TO_USE_LAZY_SKILLS_SECTION = "\n".join(
+ [
+ "### How to use skills",
+ "- Discovery: The list above is the skill index available in this session "
+ "(name + description + workspace path). In lazy mode, those paths are loaded "
+ "on demand instead of being present up front.",
+ "- Trigger rules: If the user names a skill (with `$SkillName` or plain text) "
+ "OR the task clearly matches a skill's description shown above, you must use that "
+ "skill for that turn. Multiple mentions mean use them all. Do not carry skills "
+ "across turns unless re-mentioned.",
+ "- Missing/blocked: If a named skill isn't in the list or the path can't be read, "
+ "say so briefly and continue with the best fallback.",
+ "- How to use a skill (progressive disclosure):",
+ " 1) After deciding to use a lazy skill, call `load_skill` for that skill first, "
+ "then open its `SKILL.md`.",
+ " 2) If `SKILL.md` points to extra folders such as `references/`, load only the "
+ "specific files needed for the request; don't bulk-load everything.",
+ " 3) If `scripts/` exist, prefer running or patching them instead of retyping "
+ "large code blocks.",
+ " 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.",
+ "- Coordination and sequencing:",
+ " - If multiple skills apply, choose the minimal set that covers the request "
+ "and state the order you'll use them.",
+ " - Announce which skill(s) you're using and why (one short line). "
+ "If you skip an obvious skill, say why.",
+ "- Context hygiene:",
+ " - Keep context small: summarize long sections instead of pasting them; "
+ "only load extra files when needed.",
+ " - Avoid deep reference-chasing: prefer opening only files directly linked "
+ "from `SKILL.md` unless you're blocked.",
+ " - When variants exist (frameworks, providers, domains), pick only the relevant "
+ "reference file(s) and note that choice.",
+ "- Safety and fallback: If a skill can't be applied cleanly (missing files, "
+ "unclear instructions), state the issue, pick the next-best approach, and continue.",
+ ]
+)
+
+
+@dataclass(frozen=True)
+class SkillMetadata:
+ """Indexed metadata for a skill that can be rendered into instructions."""
+
+ name: str
+ description: str
+ path: Path
+
+
+class LazySkillSource(BaseModel, abc.ABC):
+ """Source of skill metadata and on-demand skill materialization."""
+
+ @abc.abstractmethod
+ def list_skill_metadata(self, *, skills_path: str) -> list[SkillMetadata]: ...
+
+ @abc.abstractmethod
+ async def load_skill(
+ self,
+ *,
+ skill_name: str,
+ session: BaseSandboxSession,
+ skills_path: str,
+ user: str | User | None = None,
+ ) -> dict[str, str]: ...
+
+
+class LocalDirLazySkillSource(LazySkillSource):
+ """Load skills lazily from a local directory on the host filesystem."""
+
+ source: LocalDir
+
+ def _src_root(self) -> Path | None:
+ if self.source.src is None:
+ return None
+ src_root = (Path.cwd() / self.source.src).resolve()
+ if not src_root.exists() or not src_root.is_dir():
+ return None
+ return src_root
+
+ def list_skill_metadata(self, *, skills_path: str) -> list[SkillMetadata]:
+ src_root = self._src_root()
+ if src_root is None:
+ return []
+
+ metadata: list[SkillMetadata] = []
+ for child in sorted(src_root.iterdir(), key=lambda entry: entry.name):
+ if not child.is_dir():
+ continue
+ skill_md_path = child / "SKILL.md"
+ if not skill_md_path.is_file():
+ continue
+ try:
+ markdown = skill_md_path.read_text(encoding="utf-8")
+ except OSError:
+ continue
+ frontmatter = _parse_frontmatter(markdown)
+ metadata.append(
+ SkillMetadata(
+ name=frontmatter.get("name", child.name),
+ description=frontmatter.get("description", "No description provided."),
+ path=Path(skills_path) / child.name,
+ )
+ )
+ return metadata
+
+ async def load_skill(
+ self,
+ *,
+ skill_name: str,
+ session: BaseSandboxSession,
+ skills_path: str,
+ user: str | User | None = None,
+ ) -> dict[str, str]:
+ src_root = self._src_root()
+ if src_root is None:
+ raise SkillsConfigError(
+ message="lazy skill source directory is unavailable",
+ context={"skill_name": skill_name},
+ )
+
+ matches = [
+ skill
+ for skill in self.list_skill_metadata(skills_path=skills_path)
+ if skill.name == skill_name or skill.path.name == skill_name
+ ]
+ if not matches:
+ raise SkillsConfigError(
+ message="lazy skill not found",
+ context={"skill_name": skill_name, "skills_path": skills_path},
+ )
+ if len(matches) > 1:
+ raise SkillsConfigError(
+ message="lazy skill name is ambiguous",
+ context={
+ "skill_name": skill_name,
+ "matching_paths": [str(skill.path) for skill in matches],
+ },
+ )
+ metadata = matches[0]
+
+ workspace_root = Path(session.state.manifest.root)
+ skill_dest = workspace_root / metadata.path
+ skill_md_path = skill_dest / "SKILL.md"
+ try:
+ handle = await session.read(skill_md_path, user=user)
+ except Exception:
+ handle = None
+ if handle is not None:
+ handle.close()
+ return {
+ "status": "already_loaded",
+ "skill_name": metadata.name,
+ "path": str(metadata.path).replace("\\", "/"),
+ }
+
+ await LocalDir(src=src_root / metadata.path.name).apply(
+ session,
+ skill_dest,
+ base_dir=Path.cwd(),
+ user=user,
+ )
+ return {
+ "status": "loaded",
+ "skill_name": metadata.name,
+ "path": str(metadata.path).replace("\\", "/"),
+ }
+
+
+class _LoadSkillArgs(BaseModel):
+ skill_name: str
+
+
+@dataclass(init=False)
+class _LoadSkillTool(FunctionTool):
+ tool_name = "load_skill"
+ args_model = _LoadSkillArgs
+ tool_description = (
+ "Load a single lazily configured skill into the sandbox so its SKILL.md, scripts, "
+ "references, and assets can be read from the workspace."
+ )
+ skills: Skills = field(init=False, repr=False, compare=False)
+
+ def __init__(self, *, skills: Skills) -> None:
+ self.skills = skills
+ super().__init__(
+ name=self.tool_name,
+ description=self.tool_description,
+ params_json_schema=self.args_model.model_json_schema(),
+ on_invoke_tool=self._invoke,
+ strict_json_schema=False,
+ )
+
+ async def _invoke(self, _: object, raw_input: str) -> dict[str, str]:
+ return await self.run(self.args_model.model_validate_json(raw_input))
+
+ async def run(self, args: _LoadSkillArgs) -> dict[str, str]:
+ return await self.skills.load_skill(args.skill_name)
+
+
+def _validate_relative_path(
+ value: str | Path,
+ *,
+ field_name: str,
+ context: Mapping[str, object] | None = None,
+) -> Path:
+ rel = value if isinstance(value, Path) else Path(value)
+ if rel.is_absolute():
+ raise SkillsConfigError(
+ message=f"{field_name} must be a relative path",
+ context={
+ "field": field_name,
+ "path": str(rel),
+ "reason": "absolute",
+ **(context or {}),
+ },
+ )
+ if ".." in rel.parts:
+ raise SkillsConfigError(
+ message=f"{field_name} must not escape the skills root",
+ context={
+ "field": field_name,
+ "path": str(rel),
+ "reason": "escape_root",
+ **(context or {}),
+ },
+ )
+ if rel.parts in [(), (".",)]:
+ raise SkillsConfigError(
+ message=f"{field_name} must be non-empty",
+ context={"field": field_name, "path": str(rel), "reason": "empty", **(context or {})},
+ )
+ return rel
+
+
+def _manifest_entry_paths(manifest: Manifest) -> set[Path]:
+ return {key if isinstance(key, Path) else Path(key) for key in manifest.entries}
+
+
+def _get_manifest_entry_by_path(manifest: Manifest, path: Path) -> BaseEntry | None:
+ for key, entry in manifest.entries.items():
+ normalized = key if isinstance(key, Path) else Path(key)
+ if normalized == path:
+ return entry
+ return None
+
+
+def _parse_frontmatter(markdown: str) -> dict[str, str]:
+ """Parse the simple YAML frontmatter shape used by skill indexes."""
+
+ lines = markdown.splitlines()
+ if not lines or lines[0].strip() != "---":
+ return {}
+
+ end_index: int | None = None
+ for index, line in enumerate(lines[1:], start=1):
+ if line.strip() == "---":
+ end_index = index
+ break
+ if end_index is None:
+ return {}
+
+ metadata: dict[str, str] = {}
+ for line in lines[1:end_index]:
+ stripped = line.strip()
+ if stripped == "" or stripped.startswith("#") or ":" not in stripped:
+ continue
+ key, value = stripped.split(":", 1)
+ parsed_key = key.strip()
+ parsed_value = value.strip()
+ if (
+ len(parsed_value) >= 2
+ and parsed_value[0] == parsed_value[-1]
+ and parsed_value[0] in {"'", '"'}
+ ):
+ parsed_value = parsed_value[1:-1]
+ metadata[parsed_key] = parsed_value
+ return metadata
+
+
+def _read_text(handle: io.IOBase) -> str:
+ """Normalize sandbox file reads into text for metadata extraction."""
+
+ payload = handle.read()
+ if isinstance(payload, str):
+ return payload
+ if isinstance(payload, bytes | bytearray):
+ return bytes(payload).decode("utf-8", errors="replace")
+ return str(payload)
+
+
+class Skill(BaseModel):
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+ name: str
+ description: str
+ content: str | bytes | BaseEntry
+
+ compatibility: str | None = Field(default=None)
+ scripts: dict[str | Path, BaseEntry] = Field(default_factory=dict)
+ references: dict[str | Path, BaseEntry] = Field(default_factory=dict)
+ assets: dict[str | Path, BaseEntry] = Field(default_factory=dict)
+ deferred: bool = Field(default=False)
+
+ @field_validator("content", mode="before")
+ @classmethod
+ def _parse_content(cls, value: object) -> object:
+ if isinstance(value, Mapping):
+ return BaseEntry.parse(value)
+ return value
+
+ @field_validator("scripts", "references", "assets", mode="before")
+ @classmethod
+ def _parse_entry_map(cls, value: object) -> dict[str | Path, BaseEntry]:
+ if value is None:
+ return {}
+ if not isinstance(value, Mapping):
+ raise TypeError(f"Artifact mapping must be a mapping, got {type(value).__name__}")
+ return {key: BaseEntry.parse(entry) for key, entry in value.items()}
+
+ def model_post_init(self, context: Any, /) -> None:
+ _ = context
+ skill_context = {"skill_name": self.name}
+ _validate_relative_path(self.name, field_name="name", context=skill_context)
+
+ content_artifact = self.content_artifact()
+ if not isinstance(content_artifact, File | LocalFile):
+ raise SkillsConfigError(
+ message="skill content must be file-like",
+ context={
+ "field": "content",
+ "skill_name": self.name,
+ "content_type": content_artifact.type,
+ },
+ )
+
+ self.scripts = self._normalize_entry_map(self.scripts, field_name="scripts")
+ self.references = self._normalize_entry_map(self.references, field_name="references")
+ self.assets = self._normalize_entry_map(self.assets, field_name="assets")
+
+ def _normalize_entry_map(
+ self,
+ entries: Mapping[str | Path, BaseEntry],
+ *,
+ field_name: str,
+ ) -> dict[str | Path, BaseEntry]:
+ normalized: dict[str | Path, BaseEntry] = {}
+ seen_paths: set[str] = set()
+ for key, artifact in entries.items():
+ rel = _validate_relative_path(
+ key,
+ field_name=field_name,
+ context={"skill_name": self.name, "entry_path": str(key)},
+ )
+ rel_str = rel.as_posix()
+ if rel_str in seen_paths:
+ raise SkillsConfigError(
+ message=f"duplicate entry path in skill {field_name}",
+ context={
+ "skill_name": self.name,
+ "field": field_name,
+ "entry_path": rel_str,
+ },
+ )
+ seen_paths.add(rel_str)
+ normalized[rel_str] = artifact
+ return normalized
+
+ def content_artifact(self) -> BaseEntry:
+ if isinstance(self.content, bytes):
+ return File(content=self.content)
+ if isinstance(self.content, str):
+ return File(content=self.content.encode("utf-8"))
+ return self.content
+
+ def as_dir_entry(self) -> Dir:
+ children: dict[str | Path, BaseEntry] = {"SKILL.md": self.content_artifact()}
+ if self.scripts:
+ children["scripts"] = Dir(children=self.scripts)
+ if self.references:
+ children["references"] = Dir(children=self.references)
+ if self.assets:
+ children["assets"] = Dir(children=self.assets)
+ return Dir(children=children)
+
+
+class Skills(Capability):
+ """Mount skills into a Codex auto-discovery root inside the sandbox."""
+
+ type: Literal["skills"] = "skills"
+ skills: list[Skill] = Field(default_factory=list)
+ from_: BaseEntry | None = Field(default=None)
+ lazy_from: LazySkillSource | None = Field(default=None)
+ skills_path: str = Field(default=".agents")
+
+ _skills_metadata: list[SkillMetadata] | None = PrivateAttr(default=None)
+
+ @field_validator("skills", mode="before")
+ @classmethod
+ def _coerce_skills(
+ cls,
+ value: Sequence[Skill | Mapping[str, object]] | None,
+ ) -> list[Skill]:
+ if value is None:
+ return []
+ return [
+ skill if isinstance(skill, Skill) else Skill.model_validate(dict(skill))
+ for skill in value
+ ]
+
+ @field_validator("from_", mode="before")
+ @classmethod
+ def _coerce_entry(
+ cls,
+ entry: BaseEntry | Mapping[str, object] | None,
+ ) -> BaseEntry | None:
+ if entry is None or isinstance(entry, BaseEntry):
+ return entry
+ return BaseEntry.parse(entry)
+
+ def model_post_init(self, context: Any, /) -> None:
+ _ = context
+ skills_root = _validate_relative_path(self.skills_path, field_name="skills_path")
+ self.skills_path = str(skills_root)
+
+ if not self.skills and self.from_ is None and self.lazy_from is None:
+ raise SkillsConfigError(
+ message="skills capability requires `skills`, `from_`, or `lazy_from`",
+ context={"field": "skills"},
+ )
+
+ configured_sources = sum(
+ 1
+ for has_source in (
+ bool(self.skills),
+ self.from_ is not None,
+ self.lazy_from is not None,
+ )
+ if has_source
+ )
+ if configured_sources > 1:
+ raise SkillsConfigError(
+ message="skills capability accepts only one of `skills`, `from_`, or `lazy_from`",
+ context={"field": "skills", "has_from": self.from_ is not None},
+ )
+
+ if self.from_ is not None and not self.from_.is_dir:
+ raise SkillsConfigError(
+ message="`from_` must be a directory-like artifact",
+ context={"field": "from_", "artifact_type": self.from_.type},
+ )
+
+ seen_names: set[Path] = set()
+ for skill in self.skills:
+ rel = _validate_relative_path(
+ skill.name,
+ field_name="skills[].name",
+ context={"skill_name": skill.name},
+ )
+ if rel in seen_names:
+ raise SkillsConfigError(
+ message=f"duplicate skill name: {skill.name}",
+ context={"field": "skills[].name", "skill_name": skill.name},
+ )
+ seen_names.add(rel)
+
+ def process_manifest(self, manifest: Manifest) -> Manifest:
+ skills_root = Path(self.skills_path)
+ existing_paths = _manifest_entry_paths(manifest)
+
+ if self.lazy_from:
+ # Lazy sources do not claim `skills_root` in the manifest up front, so reserve the
+ # whole namespace here and fail fast if any existing manifest entry is equal to,
+ # above, or below that path.
+ overlaps = sorted(
+ str(path)
+ for path in existing_paths
+ if path == skills_root or path in skills_root.parents or skills_root in path.parents
+ )
+ if overlaps:
+ raise SkillsConfigError(
+ message="skills lazy_from path overlaps existing manifest entries",
+ context={
+ "path": str(skills_root),
+ "source": "lazy_from",
+ "overlaps": overlaps,
+ },
+ )
+ return manifest
+
+ if self.from_:
+ if skills_root in existing_paths:
+ existing_entry = _get_manifest_entry_by_path(manifest, skills_root)
+ if existing_entry is None:
+ raise SkillsConfigError(
+ message="skills root path lookup failed",
+ context={"path": str(skills_root), "source": "from_"},
+ )
+ if existing_entry.is_dir:
+ return manifest
+ raise SkillsConfigError(
+ message="skills root path already exists in manifest",
+ context={
+ "path": str(skills_root),
+ "source": "from_",
+ "existing_type": existing_entry.type,
+ },
+ )
+ manifest.entries[skills_root] = self.from_
+ existing_paths.add(skills_root)
+
+ for skill in self.skills:
+ relative_path = skills_root / Path(skill.name)
+ rendered_skill = skill.as_dir_entry()
+ if relative_path in existing_paths:
+ existing_entry = _get_manifest_entry_by_path(manifest, relative_path)
+ if existing_entry is None:
+ raise SkillsConfigError(
+ message="skill path lookup failed",
+ context={"path": str(relative_path), "skill_name": skill.name},
+ )
+ if existing_entry == rendered_skill:
+ continue
+ raise SkillsConfigError(
+ message="skill path already exists in manifest",
+ context={"path": str(relative_path), "skill_name": skill.name},
+ )
+ manifest.entries[relative_path] = rendered_skill
+ existing_paths.add(relative_path)
+
+ return manifest
+
+ def bind(self, session: BaseSandboxSession) -> None:
+ super().bind(session)
+ self._skills_metadata = None
+
+ def tools(self) -> list[Tool]:
+ if self.lazy_from is None:
+ return []
+ if self.session is None:
+ raise ValueError(f"{type(self).__name__} is not bound to a SandboxSession")
+ return [_LoadSkillTool(skills=self)]
+
+ async def load_skill(self, skill_name: str) -> dict[str, str]:
+ if self.lazy_from is None:
+ raise SkillsConfigError(
+ message="load_skill is only available when lazy_from is configured",
+ context={"skill_name": skill_name},
+ )
+ if self.session is None:
+ raise ValueError(f"{type(self).__name__} is not bound to a SandboxSession")
+ return await self.lazy_from.load_skill(
+ skill_name=skill_name,
+ session=self.session,
+ skills_path=self.skills_path,
+ user=self.run_as,
+ )
+
+ async def _resolve_runtime_metadata(self, manifest: Manifest) -> list[SkillMetadata]:
+ if self.session is None:
+ return []
+
+ skills_root = Path(manifest.root) / Path(self.skills_path)
+ try:
+ entries = await self.session.ls(skills_root, user=self.run_as)
+ except Exception:
+ return []
+
+ metadata: list[SkillMetadata] = []
+ for entry in entries:
+ if not entry.is_dir():
+ continue
+
+ skill_dir = Path(entry.path)
+ skill_name = skill_dir.name
+ skill_path = Path(self.skills_path) / skill_name
+ skill_md_path = skill_dir / "SKILL.md"
+
+ try:
+ handle = await self.session.read(skill_md_path, user=self.run_as)
+ except Exception:
+ continue
+
+ try:
+ markdown = _read_text(handle)
+ finally:
+ handle.close()
+
+ frontmatter = _parse_frontmatter(markdown)
+ metadata.append(
+ SkillMetadata(
+ name=frontmatter.get("name", skill_name),
+ description=frontmatter.get("description", "No description provided."),
+ path=skill_path,
+ )
+ )
+ return metadata
+
+ async def _skill_metadata(self, manifest: Manifest) -> list[SkillMetadata]:
+ if self._skills_metadata is not None:
+ return self._skills_metadata
+
+ metadata: list[SkillMetadata] = []
+
+ for skill in self.skills:
+ metadata.append(
+ SkillMetadata(
+ name=skill.name,
+ description=skill.description,
+ path=Path(self.skills_path) / skill.name,
+ )
+ )
+
+ if self.lazy_from is not None:
+ metadata.extend(self.lazy_from.list_skill_metadata(skills_path=self.skills_path))
+ elif self.from_ is not None:
+ metadata.extend(await self._resolve_runtime_metadata(manifest))
+
+ if isinstance(self.from_, Dir) and not metadata:
+ for key, entry in self.from_.children.items():
+ if not isinstance(entry, Dir):
+ continue
+ skill_name = str(key if isinstance(key, Path) else Path(key))
+ metadata.append(
+ SkillMetadata(
+ name=skill_name,
+ description=entry.description or "No description provided.",
+ path=Path(self.skills_path) / skill_name,
+ )
+ )
+
+ deduped: dict[tuple[str, str], SkillMetadata] = {}
+ for item in metadata:
+ deduped[(item.name, str(item.path))] = item
+
+ self._skills_metadata = sorted(deduped.values(), key=lambda item: item.name)
+ return self._skills_metadata
+
+ async def instructions(self, manifest: Manifest) -> str | None:
+ skills = await self._skill_metadata(manifest)
+ if not skills:
+ return None
+
+ available_skill_lines: list[str] = []
+ for skill in skills:
+ path_str = str(skill.path).replace("\\", "/")
+ available_skill_lines.append(f"- {skill.name}: {skill.description} (file: {path_str})")
+
+ how_to_use_section = (
+ _HOW_TO_USE_LAZY_SKILLS_SECTION
+ if self.lazy_from is not None
+ else _HOW_TO_USE_SKILLS_SECTION
+ )
+ return "\n".join(
+ [
+ "## Skills",
+ _SKILLS_SECTION_INTRO,
+ "### Available skills",
+ *available_skill_lines,
+ *(
+ [
+ "### Lazy loading",
+ "- These skills are indexed for planning, but they are not materialized "
+ "in the workspace yet.",
+ "- Call `load_skill` with a single skill name from the list before "
+ "reading its `SKILL.md` or other files from the workspace.",
+ "- `load_skill` stages exactly one skill under the listed path. "
+ "If you need more than one skill, call it multiple times.",
+ ]
+ if self.lazy_from is not None
+ else []
+ ),
+ how_to_use_section,
+ ]
+ )
diff --git a/src/agents/sandbox/capabilities/tools/__init__.py b/src/agents/sandbox/capabilities/tools/__init__.py
new file mode 100644
index 00000000..ae8890e8
--- /dev/null
+++ b/src/agents/sandbox/capabilities/tools/__init__.py
@@ -0,0 +1,14 @@
+from .apply_patch_tool import SandboxApplyPatchEditor, SandboxApplyPatchTool
+from .shell_tool import ExecCommandArgs, ExecCommandTool, WriteStdinArgs, WriteStdinTool
+from .view_image import ViewImageArgs, ViewImageTool
+
+__all__ = [
+ "ExecCommandArgs",
+ "ExecCommandTool",
+ "SandboxApplyPatchEditor",
+ "SandboxApplyPatchTool",
+ "ViewImageArgs",
+ "ViewImageTool",
+ "WriteStdinArgs",
+ "WriteStdinTool",
+]
diff --git a/src/agents/sandbox/capabilities/tools/apply_patch_tool.py b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py
new file mode 100644
index 00000000..20ffb10b
--- /dev/null
+++ b/src/agents/sandbox/capabilities/tools/apply_patch_tool.py
@@ -0,0 +1,370 @@
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+from ....editor import ApplyPatchEditor, ApplyPatchOperation, ApplyPatchResult
+from ....run_context import RunContextWrapper
+from ....tool import (
+ ApplyPatchApprovalFunction,
+ ApplyPatchOnApprovalFunction,
+ CustomTool,
+ CustomToolApprovalFunction,
+)
+from ....tool_context import ToolContext
+from ....util._approvals import evaluate_needs_approval_setting
+from ...apply_patch import WorkspaceEditor
+from ...session.base_sandbox_session import BaseSandboxSession
+from ...types import User
+
+_APPLY_PATCH_CUSTOM_TOOL_GRAMMAR = r"""
+start: begin_patch hunk+ end_patch
+begin_patch: "*** Begin Patch" LF
+end_patch: "*** End Patch" LF?
+
+hunk: add_hunk | delete_hunk | update_hunk
+add_hunk: "*** Add File: " filename LF add_line+
+delete_hunk: "*** Delete File: " filename LF
+update_hunk: "*** Update File: " filename LF change_move? change?
+
+filename: /(.+)/
+add_line: "+" /(.*)/ LF -> line
+
+change_move: "*** Move to: " filename LF
+change: (change_context | change_line)+ eof_line?
+change_context: ("@@" | "@@ " /(.+)/) LF
+change_line: ("+" | "-" | " ") /(.*)/ LF
+eof_line: "*** End of File" LF
+
+%import common.LF
+""".strip()
+
+_APPLY_PATCH_CUSTOM_TOOL_DESCRIPTION = r"""
+Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.
+Your patch language is a stripped-down, file-oriented diff format designed to be easy to
+parse and safe to apply. You can think of it as a high-level envelope:
+
+*** Begin Patch
+[ one or more file sections ]
+*** End Patch
+
+Within that envelope, you get a sequence of file operations.
+You MUST include a header to specify the action you are taking.
+Each operation starts with one of three headers:
+
+*** Add File: - create a new file. Every following line is a + line (the initial contents).
+*** Delete File: - remove an existing file. Nothing follows.
+*** Update File: - patch an existing file in place (optionally with a rename).
+
+May be immediately followed by *** Move to: if you want to rename the file.
+Then one or more hunks, each introduced by @@ (optionally followed by a hunk header).
+Within a hunk, each line starts with a space, -, or +.
+
+For context lines:
+- By default, show 3 lines of code immediately above and 3 lines immediately below each
+change. If a change is within 3 lines of a previous change, do NOT duplicate the first
+change's post-context lines in the second change's pre-context lines.
+- If 3 lines of context is insufficient to uniquely identify the snippet of code within the
+file, use the @@ operator to indicate the class or function to which the snippet belongs.
+For instance:
+@@ class BaseClass
+[3 lines of pre-context]
+-[old_code]
++[new_code]
+[3 lines of post-context]
+
+- If a code block is repeated so many times in a class or function that a single @@ statement
+and 3 lines of context cannot uniquely identify the snippet, use multiple @@ statements to
+jump to the right context. For instance:
+
+@@ class BaseClass
+@@ def method():
+[3 lines of pre-context]
+-[old_code]
++[new_code]
+[3 lines of post-context]
+
+The full grammar definition is below:
+Patch := Begin { FileOp } End
+Begin := "*** Begin Patch" NEWLINE
+End := "*** End Patch" NEWLINE
+FileOp := AddFile | DeleteFile | UpdateFile
+AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
+DeleteFile := "*** Delete File: " path NEWLINE
+UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
+MoveTo := "*** Move to: " newPath NEWLINE
+Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
+HunkLine := (" " | "-" | "+") text NEWLINE
+
+A full patch can combine several operations:
+
+*** Begin Patch
+*** Add File: hello.txt
++Hello world
+*** Update File: src/app.py
+*** Move to: src/main.py
+@@ def greet():
+-print("Hi")
++print("Hello, world!")
+*** Delete File: obsolete.txt
+*** End Patch
+
+Important:
+- You must include a header with your intended action (Add/Delete/Update).
+- You must prefix new lines with + even when creating a new file.
+- File references can only be relative, NEVER ABSOLUTE.
+""".strip()
+
+_APPLY_PATCH_CUSTOM_TOOL_CONFIG: dict[str, Any] = {
+ "type": "custom",
+ "name": "apply_patch",
+ "description": _APPLY_PATCH_CUSTOM_TOOL_DESCRIPTION,
+ "format": {
+ "type": "grammar",
+ "syntax": "lark",
+ "definition": _APPLY_PATCH_CUSTOM_TOOL_GRAMMAR,
+ },
+}
+
+_BEGIN_PATCH = "*** Begin Patch"
+_END_PATCH = "*** End Patch"
+_ADD_FILE = "*** Add File: "
+_DELETE_FILE = "*** Delete File: "
+_UPDATE_FILE = "*** Update File: "
+_MOVE_TO = "*** Move to: "
+
+
+class SandboxApplyPatchEditor(ApplyPatchEditor):
+ def __init__(self, session: BaseSandboxSession, *, user: str | User | None = None) -> None:
+ self.session = session
+ self.user = user
+
+ async def create_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
+ return await WorkspaceEditor(self.session, user=self.user).apply_operation(operation)
+
+ async def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
+ return await WorkspaceEditor(self.session, user=self.user).apply_operation(operation)
+
+ async def delete_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
+ return await WorkspaceEditor(self.session, user=self.user).apply_operation(operation)
+
+
+class SandboxApplyPatchTool(CustomTool):
+ # `CustomTool` stores raw-input approval callbacks, but this sandbox wrapper exposes
+ # operation-typed approval callbacks publicly and adapts them at runtime.
+ needs_approval: bool | ApplyPatchApprovalFunction = False # type: ignore[assignment]
+ on_approval: ApplyPatchOnApprovalFunction | None = None
+
+ def __init__(
+ self,
+ *,
+ session: BaseSandboxSession,
+ user: str | User | None = None,
+ needs_approval: bool | ApplyPatchApprovalFunction = False,
+ on_approval: ApplyPatchOnApprovalFunction | None = None,
+ ) -> None:
+ self.session = session
+ self.editor = SandboxApplyPatchEditor(session, user=user)
+ super().__init__(
+ name="apply_patch",
+ description=_APPLY_PATCH_CUSTOM_TOOL_DESCRIPTION,
+ format=_APPLY_PATCH_CUSTOM_TOOL_CONFIG["format"],
+ on_invoke_tool=self._on_invoke_tool,
+ needs_approval=False,
+ on_approval=on_approval,
+ )
+ self.needs_approval = needs_approval
+ self.on_approval = on_approval
+
+ @property
+ def operation_needs_approval(self) -> bool | ApplyPatchApprovalFunction:
+ return self.needs_approval
+
+ @operation_needs_approval.setter
+ def operation_needs_approval(self, value: bool | ApplyPatchApprovalFunction) -> None:
+ self.needs_approval = value
+
+ def runtime_needs_approval(self) -> CustomToolApprovalFunction:
+ return self._needs_custom_approval
+
+ def parse_custom_input(self, raw_input: str) -> list[ApplyPatchOperation]:
+ return _parse_custom_tool_input(raw_input)
+
+ async def _needs_custom_approval(
+ self, ctx_wrapper: RunContextWrapper[Any], raw_input: str, call_id: str
+ ) -> bool:
+ try:
+ operations = self.parse_custom_input(raw_input)
+ except ValueError:
+ # Let malformed patches flow through normal tool execution so the model gets a
+ # recoverable tool error instead of aborting the whole run during approval pre-checks.
+ return False
+
+ for operation in operations:
+ if await evaluate_needs_approval_setting(
+ self.needs_approval,
+ ctx_wrapper,
+ operation,
+ call_id,
+ ):
+ return True
+ return False
+
+ async def _on_invoke_tool(self, ctx: ToolContext[Any], raw_input: str) -> str:
+ operation_outputs: list[str] = []
+ for operation in self.parse_custom_input(raw_input):
+ operation.ctx_wrapper = ctx
+ if operation.type == "create_file":
+ result = await self.editor.create_file(operation)
+ elif operation.type == "update_file":
+ result = await self.editor.update_file(operation)
+ elif operation.type == "delete_file":
+ result = await self.editor.delete_file(operation)
+ else:
+ raise ValueError(f"Unsupported apply_patch operation: {operation.type}")
+ if result.output:
+ operation_outputs.append(result.output)
+ return "\n".join(operation_outputs)
+
+
+def _parse_custom_tool_input(raw_input: str) -> list[ApplyPatchOperation]:
+ stripped_input = raw_input.lstrip()
+ if stripped_input.startswith(("{", "[")):
+ return _parse_apply_patch_json(raw_input)
+ return _parse_apply_patch_input(raw_input)
+
+
+def _parse_apply_patch_json(raw_input: str) -> list[ApplyPatchOperation]:
+ payload = json.loads(raw_input)
+ if isinstance(payload, Mapping):
+ operations = payload.get("operations")
+ if isinstance(operations, Sequence) and not isinstance(operations, str | bytes):
+ return [_parse_apply_patch_operation_json(operation) for operation in operations]
+ operation = payload.get("operation")
+ if operation is not None:
+ return [_parse_apply_patch_operation_json(operation)]
+ return [_parse_apply_patch_operation_json(payload)]
+ if isinstance(payload, Sequence) and not isinstance(payload, str | bytes):
+ return [_parse_apply_patch_operation_json(operation) for operation in payload]
+ raise ValueError("apply_patch JSON input must be an object or array")
+
+
+def _parse_apply_patch_operation_json(operation: object) -> ApplyPatchOperation:
+ if not isinstance(operation, Mapping):
+ raise ValueError("apply_patch operation must be an object")
+
+ raw_type = operation.get("type")
+ raw_path = operation.get("path")
+ raw_diff = operation.get("diff")
+ if raw_type not in {"create_file", "update_file", "delete_file"}:
+ raise ValueError(f"Invalid apply_patch operation type: {raw_type}")
+ if not isinstance(raw_path, str) or not raw_path:
+ raise ValueError("apply_patch operation is missing a path")
+ if raw_type in {"create_file", "update_file"} and not isinstance(raw_diff, str):
+ raise ValueError(f"apply_patch operation {raw_type} is missing a diff")
+ if raw_type == "delete_file":
+ raw_diff = None
+
+ raw_move_to = operation.get("move_to")
+ if raw_move_to is not None and not isinstance(raw_move_to, str):
+ raise ValueError("apply_patch operation move_to must be a string")
+
+ return ApplyPatchOperation(
+ type=raw_type,
+ path=raw_path,
+ diff=raw_diff,
+ move_to=raw_move_to,
+ )
+
+
+def _parse_apply_patch_input(raw_input: str) -> list[ApplyPatchOperation]:
+ lines = raw_input.splitlines()
+ if not lines or lines[0] != _BEGIN_PATCH:
+ raise ValueError("apply_patch input must start with '*** Begin Patch'")
+ if len(lines) < 2 or lines[-1] != _END_PATCH:
+ raise ValueError("apply_patch input must end with '*** End Patch'")
+
+ operations: list[ApplyPatchOperation] = []
+ index = 1
+ while index < len(lines) - 1:
+ line = lines[index]
+ if line.startswith(_ADD_FILE):
+ parsed, index = _parse_add_file(lines, index)
+ elif line.startswith(_DELETE_FILE):
+ parsed, index = _parse_delete_file(lines, index)
+ elif line.startswith(_UPDATE_FILE):
+ parsed, index = _parse_update_file(lines, index)
+ else:
+ raise ValueError(f"Invalid apply_patch file operation header: {line}")
+ operations.append(parsed)
+
+ if not operations:
+ raise ValueError("apply_patch input must include at least one file operation")
+ return operations
+
+
+def _parse_add_file(lines: list[str], index: int) -> tuple[ApplyPatchOperation, int]:
+ path = _parse_path_header(lines[index], _ADD_FILE)
+ index += 1
+ diff_lines: list[str] = []
+ while index < len(lines) - 1 and not _is_file_operation_header(lines[index]):
+ line = lines[index]
+ if not line.startswith("+"):
+ raise ValueError(f"Invalid Add File line: {line}")
+ diff_lines.append(line)
+ index += 1
+ if not diff_lines:
+ raise ValueError(f"Add File patch for {path} must include at least one + line")
+ return (
+ ApplyPatchOperation(type="create_file", path=path, diff=_join_diff(diff_lines)),
+ index,
+ )
+
+
+def _parse_delete_file(lines: list[str], index: int) -> tuple[ApplyPatchOperation, int]:
+ path = _parse_path_header(lines[index], _DELETE_FILE)
+ index += 1
+ if index < len(lines) - 1 and not _is_file_operation_header(lines[index]):
+ raise ValueError(f"Delete File patch for {path} must not include a diff")
+ return ApplyPatchOperation(type="delete_file", path=path), index
+
+
+def _parse_update_file(lines: list[str], index: int) -> tuple[ApplyPatchOperation, int]:
+ path = _parse_path_header(lines[index], _UPDATE_FILE)
+ index += 1
+ move_to: str | None = None
+ if index < len(lines) - 1 and lines[index].startswith(_MOVE_TO):
+ move_to = _parse_path_header(lines[index], _MOVE_TO)
+ index += 1
+
+ diff_lines: list[str] = []
+ while index < len(lines) - 1 and not _is_file_operation_header(lines[index]):
+ diff_lines.append(lines[index])
+ index += 1
+ if not diff_lines:
+ raise ValueError(f"Update File patch for {path} must include a hunk")
+ return (
+ ApplyPatchOperation(
+ type="update_file",
+ path=path,
+ diff=_join_diff(diff_lines),
+ move_to=move_to,
+ ),
+ index,
+ )
+
+
+def _parse_path_header(line: str, prefix: str) -> str:
+ path = line.removeprefix(prefix).strip()
+ if not path:
+ raise ValueError(f"Missing path in apply_patch header: {line}")
+ return path
+
+
+def _is_file_operation_header(line: str) -> bool:
+ return line.startswith((_ADD_FILE, _DELETE_FILE, _UPDATE_FILE))
+
+
+def _join_diff(lines: list[str]) -> str:
+ return "\n".join(lines) + "\n"
diff --git a/src/agents/sandbox/capabilities/tools/shell_tool.py b/src/agents/sandbox/capabilities/tools/shell_tool.py
new file mode 100644
index 00000000..d85b85b2
--- /dev/null
+++ b/src/agents/sandbox/capabilities/tools/shell_tool.py
@@ -0,0 +1,323 @@
+from __future__ import annotations
+
+import shlex
+import time
+import uuid
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, ClassVar
+
+from pydantic import BaseModel, Field
+
+from ....run_context import RunContextWrapper
+from ....tool import FunctionTool
+from ...errors import ExecTimeoutError, ExecTransportError, PtySessionNotFoundError
+from ...session.base_sandbox_session import BaseSandboxSession
+from ...types import User
+from ...util.token_truncation import formatted_truncate_text_with_token_count
+
+_DEFAULT_EXEC_YIELD_TIME_MS = 10_000
+_DEFAULT_WRITE_STDIN_YIELD_TIME_MS = 250
+_TOOL_OUTPUT_HEADER = "Output:"
+
+
+def _truncate_output(text: str, max_output_tokens: int | None) -> tuple[str, int | None]:
+ return formatted_truncate_text_with_token_count(text, max_output_tokens)
+
+
+def _supports_transport_fallback(exc: ExecTransportError) -> bool:
+ return exc.context.get("retry_safe") is True
+
+
+def _format_response(
+ *,
+ output: str,
+ wall_time_seconds: float,
+ exit_code: int | None,
+ process_id: int | None = None,
+ original_token_count: int | None = None,
+) -> str:
+ sections = [f"Chunk ID: {uuid.uuid4().hex[:6]}", f"Wall time: {wall_time_seconds:.4f} seconds"]
+
+ if exit_code is not None:
+ sections.append(f"Process exited with code {exit_code}")
+ if process_id is not None:
+ sections.append(f"Process running with session ID {process_id}")
+ if original_token_count is not None:
+ sections.append(f"Original token count: {original_token_count}")
+
+ sections.append(_TOOL_OUTPUT_HEADER)
+ sections.append(output)
+ return "\n".join(sections)
+
+
+def _prepend_notice(output: str, notice: str) -> str:
+ return notice if output == "" else f"{notice}\n{output}"
+
+
+def _normalize_output(stdout: bytes, stderr: bytes) -> str:
+ decoded_stdout = stdout.decode("utf-8", errors="replace")
+ decoded_stderr = stderr.decode("utf-8", errors="replace")
+
+ if decoded_stdout and decoded_stderr:
+ joiner = "" if decoded_stdout.endswith("\n") else "\n"
+ return f"{decoded_stdout}{joiner}{decoded_stderr}"
+ return decoded_stdout or decoded_stderr
+
+
+def _resolve_workdir_command(
+ *, session: BaseSandboxSession, command: str, workdir: str | None
+) -> str:
+ if workdir is None or workdir.strip() == "":
+ return command
+
+ resolved_workdir = session.normalize_path(Path(workdir))
+ return f"cd {shlex.quote(str(resolved_workdir))} && {command}"
+
+
+def _resolve_shell(shell: str | None, login: bool) -> bool | list[str]:
+ if shell is None:
+ if login:
+ return True
+ return ["sh", "-c"]
+
+ flag = "-lc" if login else "-c"
+ return [shell, flag]
+
+
+async def _run_one_shot_exec(
+ *,
+ session: BaseSandboxSession,
+ command: str,
+ timeout_s: float | None,
+ shell: bool | list[str],
+ max_output_tokens: int | None,
+ user: str | User | None = None,
+) -> tuple[str, int, int | None]:
+ result = await session.exec(command, timeout=timeout_s, shell=shell, user=user)
+ output = _normalize_output(result.stdout, result.stderr)
+ output, original_token_count = _truncate_output(output, max_output_tokens)
+ return output, result.exit_code, original_token_count
+
+
+class ExecCommandArgs(BaseModel):
+ cmd: str = Field(description="Shell command to execute.", min_length=1)
+ workdir: str | None = Field(
+ default=None,
+ description="Optional working directory to run the command in; defaults to the turn cwd.",
+ )
+ shell: str | None = Field(
+ default=None, description="Shell binary to launch. Defaults to the user's default shell."
+ )
+ login: bool = Field(
+ default=True, description="Whether to run the shell with -l/-i semantics. Defaults to true."
+ )
+ tty: bool = Field(
+ default=False,
+ description=(
+ "Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to "
+ "true to open a PTY and access TTY process."
+ ),
+ )
+ yield_time_ms: int = Field(
+ default=_DEFAULT_EXEC_YIELD_TIME_MS,
+ ge=0,
+ description="How long to wait (in milliseconds) for output before yielding.",
+ )
+ max_output_tokens: int | None = Field(
+ default=None,
+ ge=1,
+ description="Maximum number of tokens to return. Excess output will be truncated.",
+ )
+
+
+class WriteStdinArgs(BaseModel):
+ session_id: int = Field(description="Identifier of the running unified exec session.")
+ chars: str = Field(default="", description="Bytes to write to stdin (may be empty to poll).")
+ yield_time_ms: int = Field(
+ default=_DEFAULT_WRITE_STDIN_YIELD_TIME_MS,
+ ge=0,
+ description="How long to wait (in milliseconds) for output before yielding.",
+ )
+ max_output_tokens: int | None = Field(
+ default=None,
+ ge=1,
+ description="Maximum number of tokens to return. Excess output will be truncated.",
+ )
+
+
+@dataclass(init=False)
+class ExecCommandTool(FunctionTool):
+ tool_name: ClassVar[str] = "exec_command"
+ args_model: ClassVar[type[ExecCommandArgs]] = ExecCommandArgs
+ tool_description: ClassVar[str] = (
+ "Runs a command in a PTY, returning output or a session ID for ongoing interaction."
+ )
+ session: BaseSandboxSession = field(init=False, repr=False, compare=False)
+ user: str | User | None = field(default=None, init=False, repr=False, compare=False)
+
+ def __init__(
+ self,
+ *,
+ session: BaseSandboxSession,
+ user: str | User | None = None,
+ needs_approval: (
+ bool | Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]]
+ ) = False,
+ ) -> None:
+ self.session = session
+ self.user = user
+ super().__init__(
+ name=self.tool_name,
+ description=self.tool_description,
+ params_json_schema=self.args_model.model_json_schema(),
+ on_invoke_tool=self._invoke,
+ strict_json_schema=False,
+ needs_approval=needs_approval,
+ )
+
+ async def _invoke(self, _: object, raw_input: str) -> str:
+ return await self.run(self.args_model.model_validate_json(raw_input))
+
+ async def run(self, args: ExecCommandArgs) -> str:
+ start = time.perf_counter()
+ timeout_s = args.yield_time_ms / 1000
+ wrapped_command = _resolve_workdir_command(
+ session=self.session, command=args.cmd, workdir=args.workdir
+ )
+ shell = _resolve_shell(args.shell, args.login)
+ fallback_notice: str | None = None
+
+ try:
+ if self.session.supports_pty():
+ try:
+ update = await self.session.pty_exec_start(
+ wrapped_command,
+ shell=shell,
+ tty=args.tty,
+ user=self.user,
+ yield_time_s=timeout_s,
+ max_output_tokens=args.max_output_tokens,
+ )
+ output = update.output.decode("utf-8", errors="replace")
+ exit_code = update.exit_code
+ process_id = update.process_id
+ original_token_count = update.original_token_count
+ except ExecTransportError as exc:
+ if args.tty or not _supports_transport_fallback(exc):
+ raise
+ output, exit_code, original_token_count = await _run_one_shot_exec(
+ session=self.session,
+ command=wrapped_command,
+ timeout_s=timeout_s,
+ shell=shell,
+ max_output_tokens=args.max_output_tokens,
+ user=self.user,
+ )
+ process_id = None
+ fallback_notice = (
+ "PTY transport failed before the interactive session opened; "
+ "fell back to one-shot exec."
+ )
+ else:
+ output, exit_code, original_token_count = await _run_one_shot_exec(
+ session=self.session,
+ command=wrapped_command,
+ timeout_s=timeout_s,
+ shell=shell,
+ max_output_tokens=args.max_output_tokens,
+ user=self.user,
+ )
+ process_id = None
+ except (ExecTimeoutError, TimeoutError):
+ output = f"Command timed out after {timeout_s:.3f} seconds."
+ exit_code = None
+ process_id = None
+ original_token_count = None
+
+ if fallback_notice is not None:
+ output = _prepend_notice(output, fallback_notice)
+
+ return _format_response(
+ output=output,
+ wall_time_seconds=time.perf_counter() - start,
+ exit_code=exit_code,
+ process_id=process_id,
+ original_token_count=original_token_count,
+ )
+
+
+@dataclass(init=False)
+class WriteStdinTool(FunctionTool):
+ tool_name: ClassVar[str] = "write_stdin"
+ args_model: ClassVar[type[WriteStdinArgs]] = WriteStdinArgs
+ tool_description: ClassVar[str] = (
+ "Writes characters to an existing unified exec session and returns recent output."
+ )
+ session: BaseSandboxSession = field(init=False, repr=False, compare=False)
+
+ def __init__(
+ self,
+ *,
+ session: BaseSandboxSession,
+ needs_approval: (
+ bool | Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]]
+ ) = False,
+ ) -> None:
+ self.session = session
+ super().__init__(
+ name=self.tool_name,
+ description=self.tool_description,
+ params_json_schema=self.args_model.model_json_schema(),
+ on_invoke_tool=self._invoke,
+ strict_json_schema=False,
+ needs_approval=needs_approval,
+ )
+
+ async def _invoke(self, _: object, raw_input: str) -> str:
+ return await self.run(self.args_model.model_validate_json(raw_input))
+
+ async def run(self, args: WriteStdinArgs) -> str:
+ if not self.session.supports_pty():
+ raise RuntimeError("write_stdin is not available for non-PTY sandboxes")
+
+ start = time.perf_counter()
+ yield_time_s = args.yield_time_ms / 1000
+ try:
+ update = await self.session.pty_write_stdin(
+ session_id=args.session_id,
+ chars=args.chars,
+ yield_time_s=yield_time_s,
+ max_output_tokens=args.max_output_tokens,
+ )
+ except PtySessionNotFoundError as exc:
+ return _format_response(
+ output=f"write_stdin failed: {exc}",
+ wall_time_seconds=time.perf_counter() - start,
+ exit_code=1,
+ process_id=None,
+ original_token_count=None,
+ )
+ except RuntimeError as exc:
+ if str(exc) != "stdin is not available for this process":
+ raise
+ return _format_response(
+ output=(
+ "stdin is not available for this process. "
+ "Start the command with `tty=true` in `exec_command` before using "
+ "`write_stdin`."
+ ),
+ wall_time_seconds=time.perf_counter() - start,
+ exit_code=1,
+ process_id=None,
+ original_token_count=None,
+ )
+
+ return _format_response(
+ output=update.output.decode("utf-8", errors="replace"),
+ wall_time_seconds=time.perf_counter() - start,
+ exit_code=update.exit_code,
+ process_id=update.process_id,
+ original_token_count=update.original_token_count,
+ )
diff --git a/src/agents/sandbox/capabilities/tools/view_image.py b/src/agents/sandbox/capabilities/tools/view_image.py
new file mode 100644
index 00000000..65e8d070
--- /dev/null
+++ b/src/agents/sandbox/capabilities/tools/view_image.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+import base64
+import mimetypes
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, ClassVar
+
+from pydantic import BaseModel, Field
+
+from ....run_context import RunContextWrapper
+from ....tool import FunctionTool, ToolOutputImage
+from ...errors import WorkspaceReadNotFoundError
+from ...session.base_sandbox_session import BaseSandboxSession
+from ...types import User
+
+_MAX_IMAGE_BYTES = 10 * 1024 * 1024
+_MAX_IMAGE_SIZE_LABEL = "10MB"
+_SVG_SNIFF_BYTES = 2048
+
+
+def _detect_image_mime_type(path: Path, payload: bytes) -> str | None:
+ if payload.startswith(b"\x89PNG\r\n\x1a\n"):
+ return "image/png"
+ if payload.startswith(b"\xff\xd8\xff"):
+ return "image/jpeg"
+ if payload.startswith((b"GIF87a", b"GIF89a")):
+ return "image/gif"
+ if payload.startswith(b"RIFF") and payload[8:12] == b"WEBP":
+ return "image/webp"
+ if payload.startswith(b"BM"):
+ return "image/bmp"
+ if payload.startswith((b"II*\x00", b"MM\x00*")):
+ return "image/tiff"
+
+ snippet = payload[:_SVG_SNIFF_BYTES].lstrip().lower()
+ if snippet.startswith(b"