Sandbox Agents (#2889)
### Sandbox Agents This release adds **Sandbox Agents**, a beta SDK surface for running agents with a persistent, isolated workspace. Sandbox agents keep the normal `Agent` and `Runner` flow, but add workspace manifests, sandbox-native capabilities, sandbox clients, snapshots, and resume support so agents can work over real files, run commands, edit repositories, generate artifacts, and continue work across runs. Key pieces: - `SandboxAgent`: an `Agent` with sandbox defaults such as `default_manifest`, sandbox instructions, capabilities, and `run_as`. - `Manifest`: a fresh-workspace contract for files, directories, local files, local directories, Git repos, environment, users, groups, and mounts. - `SandboxRunConfig`: per-run sandbox wiring for client creation, live session injection, serialized session resume, manifest overrides, snapshots, and materialization concurrency limits. - Built-in capabilities for shell access, filesystem editing and image inspection, skills, memory, and compaction. - Workspace snapshots and serialized sandbox session state for reconnecting to existing work or seeding a fresh sandbox from saved contents. ### Sandbox clients and hosted providers Sandbox agents now support local, containerized, and hosted execution backends: - `UnixLocalSandboxClient` for fast local development. - `DockerSandboxClient` for container isolation and image parity. - Hosted sandbox clients for Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop, and Vercel through optional extras. The release also adds provider-specific examples and mount strategies for common storage backends, including S3, Cloudflare R2, Google Cloud Storage, Azure Blob Storage, and S3 Files where supported by the selected backend. ### Sandbox memory Adds a sandbox memory capability that lets future sandbox-agent runs learn from prior runs. Memory stores extracted lessons in the sandbox workspace, injects a concise summary into later runs, and uses progressive disclosure so agents can search deeper rollout summaries only when useful. Memory supports: - Read-only or generate-only modes. - Live updates when the agent discovers stale memory. - Multi-turn grouping through `conversation_id`, SDK `Session`, `RunConfig.group_id`, or generated run IDs. - Separate memory layouts for isolating memory across agents or workflows. - S3-backed examples for persisted memory across runs. ### Workspace mounts, snapshots, and resume This release adds a full workspace entry and mount model for sandbox sessions: - Local files and directories. - Synthetic files and directories. - Git repository entries. - Remote storage mounts for S3, R2, GCS, Azure Blob Storage, and S3 Files. - Provider-specific mount strategies across Docker, Modal, Cloudflare, Blaxel, Daytona, E2B, and Runloop. - Portable snapshots with path normalization, symlink preservation, mount-safe snapshotting, and remote snapshot support. - Resume paths through runner-managed `RunState`, explicit `SandboxSessionState`, or saved snapshots. ### Examples and tutorials Adds a large `examples/sandbox/` suite covering: - Local Unix and Docker sandbox runners. - Docker mount smoke tests for S3, GCS, Azure Blob Storage, and S3 Files. - Sandbox coding tasks with skills. - Sandbox agents as tools and handoff patterns. - Memory examples, including multi-agent/multi-turn memory and S3-backed memory. - Tax-prep and healthcare-support workflows. - Dataroom QA and metric extraction tutorials. - Repository code review tutorial. - Vision website clone tutorial. - Provider examples for Blaxel, Cloudflare, Daytona, E2B, Modal, Runloop, Temporal, and Vercel. ### Runtime, tracing, and model plumbing The release includes the runtime plumbing needed to make sandbox agents work naturally inside the existing SDK: - Runner-managed sandbox preparation, capability binding, session lifecycle, state serialization, and resume behavior. - Sandbox-aware `RunState` serialization. - Unified sandbox tracing with SDK spans. - Token usage on tracing spans. - Runner-managed prompt cache key defaults. - OpenAI agent registration and harness ID configuration. - Safer redaction of sensitive MCP tool outputs when sensitive tracing is disabled. - Additional OpenAI client/model utilities and Chat Completions coverage. ## Documentation & Other Changes - docs: add Asqav to external tracing processors list. - docs: update translated document pages. Co-authored-by: Abdulrahman Alfozan <alfozan@openai.com> Co-authored-by: Aditya Singh <60082699+adityasingh2400@users.noreply.github.com> Co-authored-by: Andi Liu <andi@openai.com> Co-authored-by: Aron <263346377+aron-cf@users.noreply.github.com> Co-authored-by: ashwinnathan-openai <ashwinnathan@openai.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: cploujoux <cploujoux@blaxel.ai> Co-authored-by: elainegan-openai <168589666+elainegan-openai@users.noreply.github.com> Co-authored-by: Elias Freider <freider@users.noreply.github.com> Co-authored-by: Erik Dunteman <erik@erikds-macbook-air.local> Co-authored-by: Jason Liu <jasonliu@openai.com> Co-authored-by: Jason Steving <32336750+jasonsteving99@users.noreply.github.com> Co-authored-by: Kazuhiro Sera <seratch@openai.com> Co-authored-by: Lovre Pešut <lovre.pesut@gmail.com> Co-authored-by: Lucas Wang <lucas_wang@lucas-futures.com> Co-authored-by: Matt Brockman <matt.brockman@e2b.dev> Co-authored-by: Mish Ushakov <mishushakov@users.noreply.github.com> Co-authored-by: Naresh <ghostwriternr@gmail.com> Co-authored-by: nicholasclark-openai <nicholasclark@openai.com> Co-authored-by: qiyaoq-oai <qiyaoq@openai.com> Co-authored-by: Scott Trinh <scott@scotttrinh.com> Co-authored-by: tode-rl <tony@runloop.ai> Co-authored-by: Wendy Jiao <wendyjiao@openai.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
+6
-1
@@ -2,7 +2,9 @@
|
||||
|
||||
Agents are the core building block in your apps. An agent is a large language model (LLM) configured with instructions, tools, and optional runtime behavior such as handoffs, guardrails, and structured outputs.
|
||||
|
||||
Use this page when you want to define or customize a single agent. If you are deciding how multiple agents should collaborate, read [Agent orchestration](multi_agent.md).
|
||||
Use this page when you want to define or customize a single plain `Agent`. If you are deciding how multiple agents should collaborate, read [Agent orchestration](multi_agent.md). If the agent should run inside an isolated workspace with manifest-defined files and sandbox-native capabilities, read [Sandbox agent concepts](sandbox/guide.md).
|
||||
|
||||
The SDK uses the Responses API by default for OpenAI models, but the distinction here is orchestration: `Agent` plus `Runner` lets the SDK manage turns, tools, guardrails, handoffs, and sessions for you. If you want to own that loop yourself, use the Responses API directly instead.
|
||||
|
||||
## Choose the next guide
|
||||
|
||||
@@ -12,6 +14,7 @@ Use this page as the hub for agent definition. Jump to the adjacent guide that m
|
||||
| --- | --- |
|
||||
| Choose a model or provider setup | [Models](models/index.md) |
|
||||
| Add capabilities to the agent | [Tools](tools.md) |
|
||||
| Run an agent against a real repo, document bundle, or isolated workspace | [Sandbox agents quickstart](sandbox_agents.md) |
|
||||
| Decide between manager-style orchestration and handoffs | [Agent orchestration](multi_agent.md) |
|
||||
| Configure handoff behavior | [Handoffs](handoffs.md) |
|
||||
| Run turns, stream events, or manage conversation state | [Running agents](running_agents.md) |
|
||||
@@ -57,6 +60,8 @@ agent = Agent(
|
||||
)
|
||||
```
|
||||
|
||||
Everything in this section applies to `Agent`. `SandboxAgent` builds on the same ideas, then adds `default_manifest`, `base_instructions`, `capabilities`, and `run_as` for workspace-scoped runs. See [Sandbox agent concepts](sandbox/guide.md).
|
||||
|
||||
## Prompt templates
|
||||
|
||||
You can reference a prompt template created in the OpenAI platform by setting `prompt`. This works with OpenAI models using the Responses API.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -2,9 +2,13 @@
|
||||
|
||||
This page covers SDK-wide defaults that you usually set once during application startup, such as the default OpenAI key or client, the default OpenAI API shape, tracing export defaults, and logging behavior.
|
||||
|
||||
These defaults still apply to sandbox-based workflows, but sandbox workspaces, sandbox clients, and session reuse are configured separately.
|
||||
|
||||
If you need to configure a specific agent or run instead, start with:
|
||||
|
||||
- [Agents](agents.md) for instructions, tools, output types, handoffs, and guardrails on a plain `Agent`.
|
||||
- [Running agents](running_agents.md) for `RunConfig`, sessions, and conversation-state options.
|
||||
- [Sandbox agents](sandbox/guide.md) for `SandboxRunConfig`, manifests, capabilities, and sandbox-client-specific workspace setup.
|
||||
- [Models](models/index.md) for model selection and provider configuration.
|
||||
- [Tracing](tracing.md) for per-run tracing metadata and custom trace processors.
|
||||
|
||||
|
||||
@@ -20,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) |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# `Sandbox`
|
||||
|
||||
::: agents.sandbox
|
||||
options:
|
||||
members:
|
||||
- SandboxAgent
|
||||
- Manifest
|
||||
- SandboxRunConfig
|
||||
- Capability
|
||||
@@ -0,0 +1,6 @@
|
||||
# `Capabilities`
|
||||
|
||||
::: agents.sandbox.capabilities.capabilities
|
||||
options:
|
||||
members:
|
||||
- Capabilities
|
||||
@@ -0,0 +1,6 @@
|
||||
# `Capability`
|
||||
|
||||
::: agents.sandbox.capabilities.capability
|
||||
options:
|
||||
members:
|
||||
- Capability
|
||||
@@ -0,0 +1,10 @@
|
||||
# `Compaction`
|
||||
|
||||
::: agents.sandbox.capabilities.compaction
|
||||
options:
|
||||
members:
|
||||
- Compaction
|
||||
- CompactionModelInfo
|
||||
- CompactionPolicy
|
||||
- DynamicCompactionPolicy
|
||||
- StaticCompactionPolicy
|
||||
@@ -0,0 +1,7 @@
|
||||
# `Filesystem`
|
||||
|
||||
::: agents.sandbox.capabilities.filesystem
|
||||
options:
|
||||
members:
|
||||
- Filesystem
|
||||
- FilesystemToolSet
|
||||
@@ -0,0 +1,6 @@
|
||||
# `Memory`
|
||||
|
||||
::: agents.sandbox.capabilities.memory
|
||||
options:
|
||||
members:
|
||||
- Memory
|
||||
@@ -0,0 +1,7 @@
|
||||
# `Shell`
|
||||
|
||||
::: agents.sandbox.capabilities.shell
|
||||
options:
|
||||
members:
|
||||
- Shell
|
||||
- ShellToolSet
|
||||
@@ -0,0 +1,10 @@
|
||||
# `Skills`
|
||||
|
||||
::: agents.sandbox.capabilities.skills
|
||||
options:
|
||||
members:
|
||||
- Skills
|
||||
- Skill
|
||||
- SkillMetadata
|
||||
- LazySkillSource
|
||||
- LocalDirLazySkillSource
|
||||
@@ -0,0 +1,16 @@
|
||||
# `Workspace entries`
|
||||
|
||||
::: agents.sandbox.entries
|
||||
options:
|
||||
members:
|
||||
- Dir
|
||||
- File
|
||||
- GitRepo
|
||||
- LocalDir
|
||||
- LocalFile
|
||||
- Mount
|
||||
- AzureBlobMount
|
||||
- GCSMount
|
||||
- R2Mount
|
||||
- S3Mount
|
||||
- S3FilesMount
|
||||
@@ -0,0 +1,10 @@
|
||||
# `Manifest`
|
||||
|
||||
::: agents.sandbox.manifest
|
||||
options:
|
||||
members:
|
||||
- Manifest
|
||||
- Environment
|
||||
- EnvEntry
|
||||
- EnvValue
|
||||
- StrEnvValue
|
||||
@@ -0,0 +1,9 @@
|
||||
# `Permissions`
|
||||
|
||||
::: agents.sandbox.types
|
||||
options:
|
||||
members:
|
||||
- User
|
||||
- Group
|
||||
- Permissions
|
||||
- FileMode
|
||||
@@ -0,0 +1,6 @@
|
||||
# `SandboxAgent`
|
||||
|
||||
::: agents.sandbox.sandbox_agent
|
||||
options:
|
||||
members:
|
||||
- SandboxAgent
|
||||
@@ -0,0 +1,9 @@
|
||||
# `Docker sandbox`
|
||||
|
||||
::: agents.sandbox.sandboxes.docker
|
||||
options:
|
||||
members:
|
||||
- DockerSandboxClient
|
||||
- DockerSandboxClientOptions
|
||||
- DockerSandboxSession
|
||||
- DockerSandboxSessionState
|
||||
@@ -0,0 +1,9 @@
|
||||
# `Unix local sandbox`
|
||||
|
||||
::: agents.sandbox.sandboxes.unix_local
|
||||
options:
|
||||
members:
|
||||
- UnixLocalSandboxClient
|
||||
- UnixLocalSandboxClientOptions
|
||||
- UnixLocalSandboxSession
|
||||
- UnixLocalSandboxSessionState
|
||||
@@ -0,0 +1,7 @@
|
||||
# `Sandbox clients`
|
||||
|
||||
::: agents.sandbox.session.sandbox_client
|
||||
options:
|
||||
members:
|
||||
- BaseSandboxClient
|
||||
- BaseSandboxClientOptions
|
||||
@@ -0,0 +1,6 @@
|
||||
# `SandboxSession`
|
||||
|
||||
::: agents.sandbox.session.sandbox_session
|
||||
options:
|
||||
members:
|
||||
- SandboxSession
|
||||
@@ -0,0 +1,6 @@
|
||||
# `SandboxSessionState`
|
||||
|
||||
::: agents.sandbox.session.sandbox_session_state
|
||||
options:
|
||||
members:
|
||||
- SandboxSessionState
|
||||
@@ -0,0 +1,11 @@
|
||||
# `SnapshotSpec`
|
||||
|
||||
::: agents.sandbox.snapshot
|
||||
options:
|
||||
members:
|
||||
- SnapshotSpec
|
||||
- LocalSnapshotSpec
|
||||
- RemoteSnapshotSpec
|
||||
- LocalSnapshot
|
||||
- RemoteSnapshot
|
||||
- resolve_snapshot
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Sandbox clients
|
||||
|
||||
Use this page to choose where sandbox work should run. In most cases, the `SandboxAgent` definition stays the same while the sandbox client and client-specific options change in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig].
|
||||
|
||||
!!! warning "Beta feature"
|
||||
|
||||
Sandbox agents are in beta. Expect details of the API, defaults, and supported capabilities to change before general availability, and expect more advanced features over time.
|
||||
|
||||
## Decision guide
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Goal | Start with | Why |
|
||||
| --- | --- | --- |
|
||||
| Fastest local iteration on macOS or Linux | `UnixLocalSandboxClient` | No extra install, simple local filesystem development. |
|
||||
| Basic container isolation | `DockerSandboxClient` | Runs work inside Docker with a specific image. |
|
||||
| Hosted execution or production-style isolation | A hosted sandbox client | Moves the workspace boundary to a provider-managed environment. |
|
||||
|
||||
</div>
|
||||
|
||||
## Local clients
|
||||
|
||||
For most users, start with one of these two sandbox clients:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Client | Install | Choose it when | Example |
|
||||
| --- | --- | --- | --- |
|
||||
| `UnixLocalSandboxClient` | none | Fastest local iteration on macOS or Linux. Good default for local development. | [Unix-local starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) |
|
||||
| `DockerSandboxClient` | `openai-agents[docker]` | You want container isolation or a specific image for local parity. | [Docker starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) |
|
||||
|
||||
</div>
|
||||
|
||||
Unix-local is the easiest way to start developing against a local filesystem. Move to Docker or a hosted provider when you need stronger environment isolation or production-style parity.
|
||||
|
||||
To switch from Unix-local to Docker, keep the agent definition the same and change only the run config:
|
||||
|
||||
```python
|
||||
from docker import from_env as docker_from_env
|
||||
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=DockerSandboxClient(docker_from_env()),
|
||||
options=DockerSandboxClientOptions(image="python:3.14-slim"),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Use this when you want container isolation or image parity. See [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py).
|
||||
|
||||
## Mounts and remote storage
|
||||
|
||||
Mount entries describe what storage to expose; mount strategies describe how a sandbox backend attaches that storage. Import the built-in mount entries and generic strategies from `agents.sandbox.entries`. Hosted-provider strategies are available from `agents.extensions.sandbox` or the provider-specific extension package.
|
||||
|
||||
Common mount options:
|
||||
|
||||
- `mount_path`: where the storage appears in the sandbox. Relative paths are resolved under the manifest root; absolute paths are used as-is.
|
||||
- `read_only`: defaults to `True`. Set `False` only when the sandbox should write back to the mounted storage.
|
||||
- `mount_strategy`: required. Use a strategy that matches both the mount entry and the sandbox backend.
|
||||
|
||||
Mounts are treated as ephemeral workspace entries. Snapshot and persistence flows detach or skip mounted paths instead of copying mounted remote storage into the saved workspace.
|
||||
|
||||
Generic local/container strategies:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Strategy or pattern | Use it when | Notes |
|
||||
| --- | --- | --- |
|
||||
| `InContainerMountStrategy(pattern=RcloneMountPattern(...))` | The sandbox image can run `rclone`. | Supports S3, GCS, R2, and Azure Blob. `RcloneMountPattern` can run in `fuse` mode or `nfs` mode. |
|
||||
| `InContainerMountStrategy(pattern=MountpointMountPattern(...))` | The image has `mount-s3` and you want Mountpoint-style S3 or S3-compatible access. | Supports `S3Mount` and `GCSMount`. |
|
||||
| `InContainerMountStrategy(pattern=FuseMountPattern(...))` | The image has `blobfuse2` and FUSE support. | Supports `AzureBlobMount`. |
|
||||
| `InContainerMountStrategy(pattern=S3FilesMountPattern(...))` | The image has `mount.s3files` and can reach an existing S3 Files mount target. | Supports `S3FilesMount`. |
|
||||
| `DockerVolumeMountStrategy(driver=...)` | Docker should attach a volume-driver-backed mount before the container starts. | Docker-only. S3, GCS, R2, and Azure Blob support `rclone`; S3 and GCS also support `mountpoint`. |
|
||||
|
||||
</div>
|
||||
|
||||
## Supported hosted platforms
|
||||
|
||||
When you need a hosted environment, the same `SandboxAgent` definition usually carries over and only the sandbox client changes in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig].
|
||||
|
||||
If you are using the published SDK instead of this repository checkout, install sandbox-client dependencies through the matching package extra.
|
||||
|
||||
For provider-specific setup notes and links for the checked-in extension examples, see [examples/sandbox/extensions/README.md](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/README.md).
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Client | Install | Example |
|
||||
| --- | --- | --- |
|
||||
| `BlaxelSandboxClient` | `openai-agents[blaxel]` | [Blaxel runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) |
|
||||
| `CloudflareSandboxClient` | `openai-agents[cloudflare]` | [Cloudflare runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/cloudflare_runner.py) |
|
||||
| `DaytonaSandboxClient` | `openai-agents[daytona]` | [Daytona runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/daytona/daytona_runner.py) |
|
||||
| `E2BSandboxClient` | `openai-agents[e2b]` | [E2B runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/e2b_runner.py) |
|
||||
| `ModalSandboxClient` | `openai-agents[modal]` | [Modal runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/modal_runner.py) |
|
||||
| `RunloopSandboxClient` | `openai-agents[runloop]` | [Runloop runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/runloop/runner.py) |
|
||||
| `VercelSandboxClient` | `openai-agents[vercel]` | [Vercel runner](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/vercel_runner.py) |
|
||||
|
||||
</div>
|
||||
|
||||
Hosted sandbox clients expose provider-specific mount strategies. Choose the backend and mount strategy that best fit your storage provider:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Backend | Mount notes |
|
||||
| --- | --- |
|
||||
| Docker | Supports `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, and `S3FilesMount` with local strategies such as `InContainerMountStrategy` and `DockerVolumeMountStrategy`. |
|
||||
| `ModalSandboxClient` | Supports Modal cloud bucket mounts with `ModalCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. You can use inline credentials or a named Modal Secret. |
|
||||
| `CloudflareSandboxClient` | Supports Cloudflare bucket mounts with `CloudflareBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated `GCSMount`. |
|
||||
| `BlaxelSandboxClient` | Supports cloud bucket mounts with `BlaxelCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and `GCSMount`. Also supports persistent Blaxel Drives with `BlaxelDriveMount` and `BlaxelDriveMountStrategy` from `agents.extensions.sandbox.blaxel`. |
|
||||
| `DaytonaSandboxClient` | Supports cloud bucket mounts with `DaytonaCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
|
||||
| `E2BSandboxClient` | Supports cloud bucket mounts with `E2BCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
|
||||
| `RunloopSandboxClient` | Supports cloud bucket mounts with `RunloopCloudBucketMountStrategy`; use it with `S3Mount`, `GCSMount`, `R2Mount`, and `AzureBlobMount`. |
|
||||
| `VercelSandboxClient` | No hosted-specific mount strategy is currently exposed. Use manifest files, repos, or other workspace inputs instead. |
|
||||
|
||||
</div>
|
||||
|
||||
The table below summarizes which remote storage entries each backend can mount directly.
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Backend | AWS S3 | Cloudflare R2 | GCS | Azure Blob Storage | S3 Files |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Docker | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| `ModalSandboxClient` | ✓ | ✓ | ✓ | - | - |
|
||||
| `CloudflareSandboxClient` | ✓ | ✓ | ✓ | - | - |
|
||||
| `BlaxelSandboxClient` | ✓ | ✓ | ✓ | - | - |
|
||||
| `DaytonaSandboxClient` | ✓ | ✓ | ✓ | ✓ | - |
|
||||
| `E2BSandboxClient` | ✓ | ✓ | ✓ | ✓ | - |
|
||||
| `RunloopSandboxClient` | ✓ | ✓ | ✓ | ✓ | - |
|
||||
| `VercelSandboxClient` | - | - | - | - | - |
|
||||
|
||||
</div>
|
||||
|
||||
For more runnable examples, browse [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox) for local, coding, memory, handoff, and agent-composition patterns, and [examples/sandbox/extensions/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox/extensions) for hosted sandbox clients.
|
||||
@@ -0,0 +1,832 @@
|
||||
# Concepts
|
||||
|
||||
!!! warning "Beta feature"
|
||||
|
||||
Sandbox agents are in beta. Expect details of the API, defaults, and supported capabilities to change before general availability, and expect more advanced features over time.
|
||||
|
||||
Modern agents work best when they can operate on real files in a filesystem. **Sandbox Agents** can make use of specialized tools and shell commands to search over and manipulate large document sets, edit files, generate artifacts, and run commands. The sandbox provides the model with a persistent workspace that the agent can use to do work on your behalf. Sandbox Agents in the Agents SDK help you easily run agents paired with a sandbox environment, making it easy to get the right files on the filesystem and orchestrate the sandboxes to make it easy to start, stop, and resume tasks at scale.
|
||||
|
||||
You define the workspace around the data the agent needs. It can start from GitHub repos, local files and directories, synthetic task files, remote filesystems such as S3 or Azure Blob Storage, and other sandbox inputs you provide.
|
||||
|
||||
<div class="sandbox-harness-image" markdown="1">
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
`SandboxAgent` is still an `Agent`. It keeps the usual agent surface such as `instructions`, `prompt`, `tools`, `handoffs`, `mcp_servers`, `model_settings`, `output_type`, guardrails, and hooks, and it still runs through the normal `Runner` APIs. What changes is the execution boundary:
|
||||
|
||||
- `SandboxAgent` defines the agent itself: the usual agent configuration plus sandbox-specific defaults like `default_manifest`, `base_instructions`, `run_as`, and capabilities such as filesystem tools, shell access, skills, memory, or compaction.
|
||||
- `Manifest` declares the desired starting contents and layout for a fresh sandbox workspace, including files, repos, mounts, and environment.
|
||||
- A sandbox session is the live isolated environment where commands run and files change.
|
||||
- [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] decides how the run gets that sandbox session, for example by injecting one directly, reconnecting from serialized sandbox session state, or creating a fresh sandbox session through a sandbox client.
|
||||
- Saved sandbox state and snapshots let later runs reconnect to prior work or seed a fresh sandbox session from saved contents.
|
||||
|
||||
`Manifest` is the fresh-session workspace contract, not the full source of truth for every live sandbox. The effective workspace for a run can instead come from a reused sandbox session, serialized sandbox session state, or a snapshot chosen at run time.
|
||||
|
||||
Throughout this page, "sandbox session" means the live execution environment managed by a sandbox client. It is different from the SDK's conversational [`Session`][agents.memory.session.Session] interfaces described in [Sessions](../sessions/index.md).
|
||||
|
||||
The outer runtime still owns approvals, tracing, handoffs, and resume bookkeeping. The sandbox session owns commands, file changes, and environment isolation. That split is a core part of the model.
|
||||
|
||||
### How the pieces fit together
|
||||
|
||||
A sandbox run combines an agent definition with per-run sandbox configuration. The runner prepares the agent, binds it to a live sandbox session, and can save state for later runs.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
agent["SandboxAgent<br/><small>full Agent + sandbox defaults</small>"]
|
||||
config["SandboxRunConfig<br/><small>client / session / resume inputs</small>"]
|
||||
runner["Runner<br/><small>prepare instructions<br/>bind capability tools</small>"]
|
||||
sandbox["sandbox session<br/><small>workspace where commands run<br/>and files change</small>"]
|
||||
saved["saved state / snapshot<br/><small>for resume or fresh-start later</small>"]
|
||||
|
||||
agent --> runner
|
||||
config --> runner
|
||||
runner --> sandbox
|
||||
sandbox --> saved
|
||||
```
|
||||
|
||||
Sandbox-specific defaults stay on `SandboxAgent`. Per-run sandbox-session choices stay in `SandboxRunConfig`.
|
||||
|
||||
Think about the lifecycle in three phases:
|
||||
|
||||
1. Define the agent and the fresh-workspace contract with `SandboxAgent`, `Manifest`, and capabilities.
|
||||
2. Execute a run by giving `Runner` a `SandboxRunConfig` that injects, resumes, or creates the sandbox session.
|
||||
3. Continue later from runner-managed `RunState`, explicit sandbox `session_state`, or a saved workspace snapshot.
|
||||
|
||||
If shell access is only one occasional tool, start with hosted shell in the [tools guide](../tools.md). Reach for sandbox agents when workspace isolation, sandbox client choice, or sandbox-session resume behavior are part of the design.
|
||||
|
||||
## When to use them
|
||||
|
||||
Sandbox agents are a good fit for workspace-centric workflows, for example:
|
||||
|
||||
- coding and debugging, for example orchestrating automated fixes for issue reports in a GitHub repo and running targeted tests
|
||||
- document processing and editing, for example extracting information from a user's financial documents and creating a completed tax-form draft
|
||||
- file-grounded review or analysis, for example checking onboarding packets, generated reports, or artifact bundles before answering
|
||||
- isolated multi-agent patterns, for example giving each reviewer or coding sub-agent its own workspace
|
||||
- multi-step workspace tasks, for example fixing a bug in one run and adding a regression test later, or resuming from snapshot or sandbox session state
|
||||
|
||||
If you do not need access to files or a living filesystem, keep using `Agent`. If shell access is just one occasional capability, add hosted shell; if the workspace boundary itself is part of the feature, use sandbox agents.
|
||||
|
||||
## Choose a sandbox client
|
||||
|
||||
Start with `UnixLocalSandboxClient` for local development. Move to `DockerSandboxClient` when you need container isolation or image parity. Move to a hosted provider when you need provider-managed execution.
|
||||
|
||||
In most cases, the `SandboxAgent` definition stays the same while the sandbox client and its options change in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]. See [Sandbox clients](clients.md) for local, Docker, hosted, and remote-mount options.
|
||||
|
||||
## Core pieces
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Layer | Main SDK pieces | What it answers |
|
||||
| --- | --- | --- |
|
||||
| Agent definition | `SandboxAgent`, `Manifest`, capabilities | What agent will run, and what fresh-session workspace contract should it start from? |
|
||||
| Sandbox execution | `SandboxRunConfig`, the sandbox client, and the live sandbox session | How does this run get a live sandbox session, and where does the work execute? |
|
||||
| Saved sandbox state | `RunState` sandbox payload, `session_state`, and snapshots | How does this workflow reconnect to prior sandbox work or seed a fresh sandbox session from saved contents? |
|
||||
|
||||
</div>
|
||||
|
||||
The main SDK pieces map onto those layers like this:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Piece | What it owns | Ask this question |
|
||||
| --- | --- | --- |
|
||||
| [`SandboxAgent`][agents.sandbox.sandbox_agent.SandboxAgent] | The agent definition | What should this agent do, and which defaults should travel with it? |
|
||||
| [`Manifest`][agents.sandbox.manifest.Manifest] | Fresh-session workspace files and folders | What files and folder should be present on the filesystem when the run starts? |
|
||||
| [`Capability`][agents.sandbox.capabilities.capability.Capability] | Sandbox-native behavior | Which tools, instruction fragments, or runtime behavior should attach to this agent? |
|
||||
| [`SandboxRunConfig`][agents.run_config.SandboxRunConfig] | Per-run sandbox client and sandbox-session source | Should this run inject, resume, or create a sandbox session? |
|
||||
| [`RunState`][agents.run_state.RunState] | Runner-managed saved sandbox state | Am I resuming a prior runner-managed workflow and carrying its sandbox state forward automatically? |
|
||||
| [`SandboxRunConfig.session_state`][agents.run_config.SandboxRunConfig.session_state] | Explicit serialized sandbox session state | Do I want to resume from sandbox state I already serialized outside `RunState`? |
|
||||
| [`SandboxRunConfig.snapshot`][agents.run_config.SandboxRunConfig.snapshot] | Saved workspace contents for fresh sandbox sessions | Should a new sandbox session start from saved files and artifacts? |
|
||||
|
||||
</div>
|
||||
|
||||
A practical design order is:
|
||||
|
||||
1. Define the fresh-session workspace contract with `Manifest`.
|
||||
2. Define the agent with `SandboxAgent`.
|
||||
3. Add built-in or custom capabilities.
|
||||
4. Decide how each run should obtain its sandbox session in `RunConfig(sandbox=SandboxRunConfig(...))`.
|
||||
|
||||
## How a sandbox run is prepared
|
||||
|
||||
At run time, the runner turns that definition into a concrete sandbox-backed run:
|
||||
|
||||
1. It resolves the sandbox session from `SandboxRunConfig`.
|
||||
If you pass `session=...`, it reuses that live sandbox session.
|
||||
Otherwise it uses `client=...` to create or resume one.
|
||||
2. It determines the effective workspace inputs for the run.
|
||||
If the run injects or resumes a sandbox session, that existing sandbox state wins.
|
||||
Otherwise the runner starts from a one-off manifest override or `agent.default_manifest`.
|
||||
This is why `Manifest` alone does not define the final live workspace for every run.
|
||||
3. It lets capabilities process the resulting manifest.
|
||||
This is how capabilities can add files, mounts, or other workspace-scoped behavior before the final agent is prepared.
|
||||
4. It builds the final instructions in a fixed order:
|
||||
the SDK's default sandbox prompt, or `base_instructions` if you explicitly override it, then `instructions`, then capability instruction fragments, then any remote-mount policy text, then a rendered filesystem tree.
|
||||
5. It binds capability tools to the live sandbox session and runs the prepared agent through the normal `Runner` APIs.
|
||||
|
||||
Sandboxing does not change what a turn means. A turn is still a model step, not a single shell command or sandbox action. There is no fixed 1:1 mapping between sandbox-side operations and turns: some work may stay inside the sandbox execution layer, while other actions return tool results, approvals, or other state that requires another model step. As a practical rule, another turn is consumed only when the agent runtime needs another model response after sandbox work has happened.
|
||||
|
||||
Those preparation steps are why `default_manifest`, `instructions`, `base_instructions`, `capabilities`, and `run_as` are the main sandbox-specific options to think about when designing a `SandboxAgent`.
|
||||
|
||||
## `SandboxAgent` options
|
||||
|
||||
These are the sandbox-specific options on top of the usual `Agent` fields:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Option | Best use |
|
||||
| --- | --- |
|
||||
| `default_manifest` | The default workspace for fresh sandbox sessions created by the runner. |
|
||||
| `instructions` | Additional role, workflow, and success criteria appended after the SDK sandbox prompt. |
|
||||
| `base_instructions` | Advanced escape hatch that replaces the SDK sandbox prompt. |
|
||||
| `capabilities` | Sandbox-native tools and behavior that should travel with this agent. |
|
||||
| `run_as` | User identity for model-facing sandbox tools such as shell commands, file reads, and patches. |
|
||||
|
||||
</div>
|
||||
|
||||
Sandbox client choice, sandbox-session reuse, manifest override, and snapshot selection belong in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig], not on the agent.
|
||||
|
||||
### `default_manifest`
|
||||
|
||||
`default_manifest` is the default [`Manifest`][agents.sandbox.manifest.Manifest] used when the runner creates a fresh sandbox session for this agent. Use it for the files, repos, helper material, output directories, and mounts the agent should usually start with.
|
||||
|
||||
This is only the default. A run can override it with `SandboxRunConfig(manifest=...)`, and a reused or resumed sandbox session keeps its existing workspace state.
|
||||
|
||||
### `instructions` and `base_instructions`
|
||||
|
||||
Use `instructions` for short rules that should survive different prompts. In a `SandboxAgent`, these instructions are appended after the SDK's sandbox base prompt, so you keep the built-in sandbox guidance and add your own role, workflow, and success criteria.
|
||||
|
||||
Use `base_instructions` only when you want to replace the SDK sandbox base prompt. Most agents should not set it.
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Put it in... | Use it for | Examples |
|
||||
| --- | --- | --- |
|
||||
| `instructions` | Stable role, workflow rules, and success criteria for the agent. | "Inspect onboarding documents, then hand off.", "Write final files into `output/`." |
|
||||
| `base_instructions` | A full replacement for the SDK sandbox base prompt. | Custom low-level sandbox wrapper prompts. |
|
||||
| the user prompt | The one-off request for this run. | "Summarize this workspace." |
|
||||
| workspace files in the manifest | Longer task specs, repo-local instructions, or bounded reference material. | `repo/task.md`, document bundles, sample packets. |
|
||||
|
||||
</div>
|
||||
|
||||
Good uses for `instructions` include:
|
||||
|
||||
- [examples/sandbox/unix_local_pty.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_pty.py) keeps the agent in one interactive process when PTY state matters.
|
||||
- [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py) forbids the sandbox reviewer from answering the user directly after inspection.
|
||||
- [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py) requires the final filled files to actually land in `output/`.
|
||||
- [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) pins the exact verification command and clarifies workspace-root-relative patch paths.
|
||||
|
||||
Avoid copying the user's one-off task into `instructions`, embedding long reference material that belongs in the manifest, restating tool docs that built-in capabilities already inject, or mixing in local installation notes the model does not need at run time.
|
||||
|
||||
If you omit `instructions`, the SDK still includes the default sandbox prompt. That is enough for low-level wrappers, but most user-facing agents should still provide explicit `instructions`.
|
||||
|
||||
### `capabilities`
|
||||
|
||||
Capabilities attach sandbox-native behavior to a `SandboxAgent`. They can shape the workspace before a run starts, append sandbox-specific instructions, expose tools that bind to the live sandbox session, and adjust model behavior or input handling for that agent.
|
||||
|
||||
Built-in capabilities include:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Capability | Add it when | Notes |
|
||||
| --- | --- | --- |
|
||||
| `Shell` | The agent needs shell access. | Adds `exec_command`, plus `write_stdin` when the sandbox client supports PTY interaction. |
|
||||
| `Filesystem` | The agent needs to edit files or inspect local images. | Adds `apply_patch` and `view_image`; patch paths are workspace-root-relative. |
|
||||
| `Skills` | You want skill discovery and materialization in the sandbox. | Prefer this over mounting `.agents` or `.agents/skills` manually for sandbox-local `SKILL.md` skills. |
|
||||
| `Memory` | Follow-on runs should read or generate memory artifacts. | Requires `Shell`; live updates also require `Filesystem`. |
|
||||
| `Compaction` | Long-running flows need context trimming after compaction items. | Adjusts model sampling and input handling. |
|
||||
|
||||
</div>
|
||||
|
||||
By default, `SandboxAgent.capabilities` uses `Capabilities.default()`, which includes `Filesystem()`, `Shell()`, and `Compaction()`. If you pass `capabilities=[...]`, that list replaces the default, so include any default capabilities you still want.
|
||||
|
||||
For skills, choose the source based on how you want them materialized:
|
||||
|
||||
- `Skills(lazy_from=LocalDirLazySkillSource(...))` is a good default for larger local skill directories because the model can discover the index first and load only what it needs.
|
||||
- `Skills(from_=LocalDir(src=...))` is better for a small local bundle you want staged up front.
|
||||
- `Skills(from_=GitRepo(repo=..., ref=...))` is the right fit when the skills themselves should come from a repository.
|
||||
|
||||
If your skills already live on disk under something like `.agents/skills/<name>/SKILL.md`, point `LocalDir(...)` at that source root and still use `Skills(...)` to expose them. Keep the default `skills_path=".agents"` unless you have an existing workspace contract that depends on a different in-sandbox layout.
|
||||
|
||||
Prefer built-in capabilities when they fit. Write a custom capability only when you need a sandbox-specific tool or instruction surface that the built-ins do not cover.
|
||||
|
||||
## Concepts
|
||||
|
||||
### Manifest
|
||||
|
||||
A [`Manifest`][agents.sandbox.manifest.Manifest] describes the workspace for a fresh sandbox session. It can set the workspace `root`, declare files and directories, copy in local files, clone Git repos, attach remote storage mounts, set environment variables, and define users or groups.
|
||||
|
||||
Manifest entry paths are workspace-relative. They cannot be absolute paths or escape the workspace with `..`, which keeps the workspace contract portable across local, Docker, and hosted clients.
|
||||
|
||||
Use manifest entries for the material the agent needs before work begins:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Manifest entry | Use it for |
|
||||
| --- | --- |
|
||||
| `File`, `Dir` | Small synthetic inputs, helper files, or output directories. |
|
||||
| `LocalFile`, `LocalDir` | Host files or directories that should be materialized into the sandbox. |
|
||||
| `GitRepo` | A repository that should be fetched into the workspace. |
|
||||
| mounts such as `S3Mount`, `GCSMount`, `R2Mount`, `AzureBlobMount`, `S3FilesMount` | External storage that should appear inside the sandbox. |
|
||||
|
||||
</div>
|
||||
|
||||
Mount entries describe what storage to expose; mount strategies describe how a sandbox backend attaches that storage. See [Sandbox clients](clients.md#mounts-and-remote-storage) for mount options and provider support.
|
||||
|
||||
Good manifest design usually means keeping the workspace contract narrow, putting long task recipes in workspace files such as `repo/task.md`, and using relative workspace paths in instructions, for example `repo/task.md` or `output/report.md`. If the agent edits files with the `Filesystem` capability's `apply_patch` tool, remember that patch paths are relative to the sandbox workspace root, not the shell `workdir`.
|
||||
|
||||
### Permissions
|
||||
|
||||
`Permissions` controls filesystem permissions for manifest entries. It is about the files the sandbox materializes, not model permissions, approval policy, or API credentials.
|
||||
|
||||
By default, manifest entries are owner-readable/writable/executable and readable/executable by group and others. Override this when staged files should be private, read-only, or executable:
|
||||
|
||||
```python
|
||||
from agents.sandbox import FileMode, Permissions
|
||||
from agents.sandbox.entries import File
|
||||
|
||||
private_notes = File(
|
||||
text="internal notes",
|
||||
permissions=Permissions(
|
||||
owner=FileMode.READ | FileMode.WRITE,
|
||||
group=FileMode.NONE,
|
||||
other=FileMode.NONE,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
`Permissions` stores separate owner, group, and other bits, plus whether the entry is a directory. You can build it directly, parse it from a mode string with `Permissions.from_str(...)`, or derive it from an OS mode with `Permissions.from_mode(...)`.
|
||||
|
||||
Users are the sandbox identities that can execute work. Add a `User` to the manifest when you want that identity to exist in the sandbox, then set `SandboxAgent.run_as` when model-facing sandbox tools such as shell commands, file reads, and patches should run as that user. If `run_as` points at a user that is not already in the manifest, the runner adds it to the effective manifest for you.
|
||||
|
||||
```python
|
||||
from agents import Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import FileMode, Manifest, Permissions, SandboxAgent, SandboxRunConfig, User
|
||||
from agents.sandbox.entries import Dir, LocalDir
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
analyst = User(name="analyst")
|
||||
|
||||
agent = SandboxAgent(
|
||||
name="Dataroom analyst",
|
||||
instructions="Review the files in `dataroom/` and write findings to `output/`.",
|
||||
default_manifest=Manifest(
|
||||
# Declare the sandbox user so manifest entries can grant access to it.
|
||||
users=[analyst],
|
||||
entries={
|
||||
"dataroom": LocalDir(
|
||||
src="./dataroom",
|
||||
# Let the analyst traverse and read the mounted dataroom, but not edit it.
|
||||
group=analyst,
|
||||
permissions=Permissions(
|
||||
owner=FileMode.READ | FileMode.EXEC,
|
||||
group=FileMode.READ | FileMode.EXEC,
|
||||
other=FileMode.NONE,
|
||||
),
|
||||
),
|
||||
"output": Dir(
|
||||
# Give the analyst a writable scratch/output directory for artifacts.
|
||||
group=analyst,
|
||||
permissions=Permissions(
|
||||
owner=FileMode.ALL,
|
||||
group=FileMode.ALL,
|
||||
other=FileMode.NONE,
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
# Run model-facing sandbox actions as this user, so those permissions apply.
|
||||
run_as=analyst,
|
||||
)
|
||||
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"Summarize the contracts and call out renewal dates.",
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
If you also need file-level sharing rules, combine users with manifest groups and entry `group` metadata. The `run_as` user controls who executes sandbox-native actions; `Permissions` controls which files that user can read, write, or execute once the sandbox has materialized the workspace.
|
||||
|
||||
### SnapshotSpec
|
||||
|
||||
`SnapshotSpec` tells a fresh sandbox session where saved workspace contents should be restored from and persisted back to. It is the snapshot policy for the sandbox workspace, while `session_state` is the serialized connection state for resuming a specific sandbox backend.
|
||||
|
||||
Use `LocalSnapshotSpec` for local durable snapshots and `RemoteSnapshotSpec` when your app provides a remote snapshot client. A no-op snapshot is used as a fallback when local snapshot setup is unavailable, and advanced callers can use one explicitly when they do not want workspace snapshot persistence.
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import LocalSnapshotSpec, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=UnixLocalSandboxClient(),
|
||||
snapshot=LocalSnapshotSpec(base_path=Path("/tmp/my-sandbox-snapshots")),
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
When the runner creates a fresh sandbox session, the sandbox client builds a snapshot instance for that session. On start, if the snapshot is restorable, the sandbox restores saved workspace contents before the run continues. On cleanup, runner-owned sandbox sessions archive the workspace and persist it back through the snapshot.
|
||||
|
||||
If you omit `snapshot`, the runtime tries to use a default local snapshot location when it can. If that cannot be set up, it falls back to a no-op snapshot. Mounted and ephemeral paths are not copied into snapshots as durable workspace contents.
|
||||
|
||||
### Sandbox lifecycle
|
||||
|
||||
There are two lifecycle modes: **SDK-owned** and **developer-owned**.
|
||||
|
||||
<div class="sandbox-lifecycle-diagram" markdown="1">
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App
|
||||
participant Runner
|
||||
participant Client
|
||||
participant Sandbox
|
||||
|
||||
App->>Runner: Runner.run(..., SandboxRunConfig(client=...))
|
||||
Runner->>Client: create or resume sandbox
|
||||
Client-->>Runner: sandbox session
|
||||
Runner->>Sandbox: start, run tools
|
||||
Runner->>Sandbox: stop and persist snapshot
|
||||
Runner->>Client: delete runner-owned resources
|
||||
|
||||
App->>Client: create(...)
|
||||
Client-->>App: sandbox session
|
||||
App->>Sandbox: async with sandbox
|
||||
App->>Runner: Runner.run(..., SandboxRunConfig(session=sandbox))
|
||||
Runner->>Sandbox: run tools
|
||||
App->>Sandbox: cleanup on context exit / aclose()
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
Use SDK-owned lifecycle when the sandbox only needs to live for one run. Pass a `client`, optional `manifest`, optional `snapshot`, and client `options`; the runner creates or resumes the sandbox, starts it, runs the agent, persists snapshot-backed workspace state, shuts the sandbox down, and lets the client clean up runner-owned resources.
|
||||
|
||||
```python
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"Inspect the workspace and summarize what changed.",
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Use developer-owned lifecycle when you want to eagerly create a sandbox, reuse one live sandbox across multiple runs, inspect files after a run, stream over a sandbox you created yourself, or decide exactly when cleanup happens. Passing `session=...` tells the runner to use that live sandbox, but not to close it for you.
|
||||
|
||||
```python
|
||||
sandbox = await client.create(manifest=agent.default_manifest)
|
||||
|
||||
async with sandbox:
|
||||
run_config = RunConfig(sandbox=SandboxRunConfig(session=sandbox))
|
||||
await Runner.run(agent, "Analyze the files.", run_config=run_config)
|
||||
await Runner.run(agent, "Write the final report.", run_config=run_config)
|
||||
```
|
||||
|
||||
The context manager is the usual shape: it starts the sandbox on entry and runs the session cleanup lifecycle on exit. If your app cannot use a context manager, call the lifecycle methods directly:
|
||||
|
||||
```python
|
||||
sandbox = await client.create(
|
||||
manifest=agent.default_manifest,
|
||||
snapshot=LocalSnapshotSpec(base_path=Path("/tmp/my-sandbox-snapshots")),
|
||||
)
|
||||
try:
|
||||
await sandbox.start()
|
||||
await Runner.run(
|
||||
agent,
|
||||
"Analyze the files.",
|
||||
run_config=RunConfig(sandbox=SandboxRunConfig(session=sandbox)),
|
||||
)
|
||||
# Persist a checkpoint of the live workspace before doing more work.
|
||||
# `aclose()` also calls `stop()`, so this is only needed for an explicit mid-lifecycle save.
|
||||
await sandbox.stop()
|
||||
finally:
|
||||
await sandbox.aclose()
|
||||
```
|
||||
|
||||
`stop()` only persists snapshot-backed workspace contents; it does not tear down the sandbox. `aclose()` is the full session cleanup path: it runs pre-stop hooks, calls `stop()`, shuts down sandbox resources, and closes session-scoped dependencies.
|
||||
|
||||
## `SandboxRunConfig` options
|
||||
|
||||
[`SandboxRunConfig`][agents.run_config.SandboxRunConfig] holds the per-run options that decide where the sandbox session comes from and how a fresh session should be initialized.
|
||||
|
||||
### Sandbox source
|
||||
|
||||
These options decide whether the runner should reuse, resume, or create the sandbox session:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Option | Use it when | Notes |
|
||||
| --- | --- | --- |
|
||||
| `client` | You want the runner to create, resume, and clean up sandbox sessions for you. | Required unless you provide a live sandbox `session`. |
|
||||
| `session` | You already created a live sandbox session yourself. | The caller owns lifecycle; the runner reuses that live sandbox session. |
|
||||
| `session_state` | You have serialized sandbox session state but not a live sandbox session object. | Requires `client`; the runner resumes from that explicit state as an owning session. |
|
||||
|
||||
</div>
|
||||
|
||||
In practice, the runner resolves the sandbox session in this order:
|
||||
|
||||
1. If you inject `run_config.sandbox.session`, that live sandbox session is reused directly.
|
||||
2. Otherwise, if the run is resuming from `RunState`, the stored sandbox session state is resumed.
|
||||
3. Otherwise, if you pass `run_config.sandbox.session_state`, the runner resumes from that explicit serialized sandbox session state.
|
||||
4. Otherwise, the runner creates a fresh sandbox session. For that fresh session, it uses `run_config.sandbox.manifest` when provided, or `agent.default_manifest` if not.
|
||||
|
||||
### Fresh-session inputs
|
||||
|
||||
These options only matter when the runner is creating a fresh sandbox session:
|
||||
|
||||
<div class="sandbox-nowrap-first-column-table" markdown="1">
|
||||
|
||||
| Option | Use it when | Notes |
|
||||
| --- | --- | --- |
|
||||
| `manifest` | You want a one-off fresh-session workspace override. | Falls back to `agent.default_manifest` when omitted. |
|
||||
| `snapshot` | A fresh sandbox session should be seeded from a snapshot. | Useful for resume-like flows or remote snapshot clients. |
|
||||
| `options` | The sandbox client needs creation-time options. | Common for Docker images, Modal app names, E2B templates, timeouts, and similar client-specific settings. |
|
||||
|
||||
</div>
|
||||
|
||||
### Materialization controls
|
||||
|
||||
`concurrency_limits` controls how much sandbox materialization work can run in parallel. Use `SandboxConcurrencyLimits(manifest_entries=..., local_dir_files=...)` when large manifests or local directory copies need tighter resource control. Set either value to `None` to disable that specific limit.
|
||||
|
||||
A few implications are worth keeping in mind:
|
||||
|
||||
- Fresh sessions: `manifest=` and `snapshot=` only apply when the runner is creating a fresh sandbox session.
|
||||
- Resume vs snapshot: `session_state=` reconnects to previously serialized sandbox state, whereas `snapshot=` seeds a new sandbox session from saved workspace contents.
|
||||
- Client-specific options: `options=` depends on the sandbox client; Docker and many hosted clients require it.
|
||||
- Injected live sessions: if you pass a running sandbox `session`, capability-driven manifest updates can add compatible non-mount entries. They cannot change `manifest.root`, `manifest.environment`, `manifest.users`, or `manifest.groups`; remove existing entries; replace entry types; or add or change mount entries.
|
||||
- Runner API: `SandboxAgent` execution still uses the normal `Runner.run()`, `Runner.run_sync()`, and `Runner.run_streamed()` APIs.
|
||||
|
||||
## Full example: coding task
|
||||
|
||||
This coding-style example is a good default starting point:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.capabilities import (
|
||||
Capabilities,
|
||||
LocalDirLazySkillSource,
|
||||
Skills,
|
||||
)
|
||||
from agents.sandbox.entries import LocalDir
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||
HOST_REPO_DIR = EXAMPLE_DIR / "repo"
|
||||
HOST_SKILLS_DIR = EXAMPLE_DIR / "skills"
|
||||
TARGET_TEST_CMD = "sh tests/test_credit_note.sh"
|
||||
|
||||
|
||||
def build_agent(model: str) -> SandboxAgent[None]:
|
||||
return SandboxAgent(
|
||||
name="Sandbox engineer",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Inspect the repo, make the smallest correct change, run the most relevant checks, "
|
||||
"and summarize the file changes and risks. "
|
||||
"Read `repo/task.md` before editing files. Stay grounded in the repository, preserve "
|
||||
"existing behavior, and mention the exact verification command you ran. "
|
||||
"Use the `$credit-note-fixer` skill before editing files. If the repo lives under "
|
||||
"`repo/`, remember that `apply_patch` paths stay relative to the sandbox workspace "
|
||||
"root, so edits still target `repo/...`."
|
||||
),
|
||||
# Put repos and task files in the manifest.
|
||||
default_manifest=Manifest(
|
||||
entries={
|
||||
"repo": LocalDir(src=HOST_REPO_DIR),
|
||||
}
|
||||
),
|
||||
capabilities=Capabilities.default() + [
|
||||
# Let Skills(...) stage and index sandbox-local skills for you.
|
||||
Skills(
|
||||
lazy_from=LocalDirLazySkillSource(
|
||||
source=LocalDir(src=HOST_SKILLS_DIR),
|
||||
)
|
||||
),
|
||||
],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
|
||||
async def main(model: str, prompt: str) -> None:
|
||||
result = await Runner.run(
|
||||
build_agent(model),
|
||||
prompt,
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
|
||||
workflow_name="Sandbox coding example",
|
||||
),
|
||||
)
|
||||
print(result.final_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(
|
||||
main(
|
||||
model="gpt-5.4",
|
||||
prompt=(
|
||||
"Open `repo/task.md`, use the `$credit-note-fixer` skill, fix the bug, "
|
||||
f"run `{TARGET_TEST_CMD}`, and summarize the change."
|
||||
),
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
See [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py). It uses a tiny shell-based repo so the example can be verified deterministically across Unix-local runs. Your real task repo can of course be Python, JavaScript, or anything else.
|
||||
|
||||
## Common patterns
|
||||
|
||||
Start from the full example above. In many cases, the same `SandboxAgent` can stay intact while only the sandbox client, sandbox-session source, or workspace source changes.
|
||||
|
||||
### Switch sandbox clients
|
||||
|
||||
Keep the agent definition the same and change only the run config. Use Docker when you want container isolation or image parity, or a hosted provider when you want provider-managed execution. See [Sandbox clients](clients.md) for examples and provider options.
|
||||
|
||||
### Override the workspace
|
||||
|
||||
Keep the agent definition the same and swap only the fresh-session manifest:
|
||||
|
||||
```python
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxRunConfig
|
||||
from agents.sandbox.entries import GitRepo
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=UnixLocalSandboxClient(),
|
||||
manifest=Manifest(
|
||||
entries={
|
||||
"repo": GitRepo(repo="openai/openai-agents-python", ref="main"),
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Use this when the same agent role should run against different repos, packets, or task bundles without rebuilding the agent. The validated coding example above shows the same pattern with `default_manifest` instead of a one-off override.
|
||||
|
||||
### Inject a sandbox session
|
||||
|
||||
Inject a live sandbox session when you need explicit lifecycle control, post-run inspection, or output copying:
|
||||
|
||||
```python
|
||||
from agents import Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
client = UnixLocalSandboxClient()
|
||||
sandbox = await client.create(manifest=agent.default_manifest)
|
||||
|
||||
async with sandbox:
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
prompt,
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Use this when you want to inspect the workspace after the run or stream over an already-started sandbox session. See [examples/sandbox/docs/coding_task.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docs/coding_task.py) and [examples/sandbox/docker/docker_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py).
|
||||
|
||||
### Resume from session state
|
||||
|
||||
If you already serialized sandbox state outside `RunState`, let the runner reconnect from that state:
|
||||
|
||||
```python
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxRunConfig
|
||||
|
||||
serialized = load_saved_payload()
|
||||
restored_state = client.deserialize_session_state(serialized)
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=client,
|
||||
session_state=restored_state,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Use this when sandbox state lives in your own storage or job system and you want `Runner` to resume from it directly. See [examples/sandbox/extensions/blaxel_runner.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/extensions/blaxel_runner.py) for the serialize/deserialize flow.
|
||||
|
||||
### Start from a snapshot
|
||||
|
||||
Seed a new sandbox from saved files and artifacts:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import LocalSnapshotSpec, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=UnixLocalSandboxClient(),
|
||||
snapshot=LocalSnapshotSpec(base_path=Path("/tmp/my-sandbox-snapshot")),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Use this when a fresh run should start from saved workspace contents rather than only `agent.default_manifest`. See [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) for a local snapshot flow and [examples/sandbox/sandbox_agent_with_remote_snapshot.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_remote_snapshot.py) for a remote snapshot client.
|
||||
|
||||
### Load skills from Git
|
||||
|
||||
Swap the local skill source for a repository-backed one:
|
||||
|
||||
```python
|
||||
from agents.sandbox.capabilities import Capabilities, Skills
|
||||
from agents.sandbox.entries import GitRepo
|
||||
|
||||
capabilities = Capabilities.default() + [
|
||||
Skills(from_=GitRepo(repo="sdcoffey/tax-prep-skills", ref="main")),
|
||||
]
|
||||
```
|
||||
|
||||
Use this when the skills bundle has its own release cadence or should be shared across sandboxes. See [examples/sandbox/tax_prep.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/tax_prep.py).
|
||||
|
||||
### Expose as tools
|
||||
|
||||
Tool-agents can either get their own sandbox boundary or reuse a live sandbox from the parent run. Reuse is useful for a fast read-only explorer agent: it can inspect the exact workspace the parent is using without paying to create, hydrate, or snapshot another sandbox.
|
||||
|
||||
```python
|
||||
from agents import Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import FileMode, Manifest, Permissions, SandboxAgent, SandboxRunConfig, User
|
||||
from agents.sandbox.entries import Dir, File
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
coordinator = User(name="coordinator")
|
||||
explorer = User(name="explorer")
|
||||
|
||||
manifest = Manifest(
|
||||
users=[coordinator, explorer],
|
||||
entries={
|
||||
"pricing_packet": Dir(
|
||||
group=coordinator,
|
||||
permissions=Permissions(
|
||||
owner=FileMode.ALL,
|
||||
group=FileMode.ALL,
|
||||
other=FileMode.READ | FileMode.EXEC,
|
||||
directory=True,
|
||||
),
|
||||
children={
|
||||
"pricing.md": File(
|
||||
content=b"Pricing packet contents...",
|
||||
group=coordinator,
|
||||
permissions=Permissions(
|
||||
owner=FileMode.ALL,
|
||||
group=FileMode.ALL,
|
||||
other=FileMode.READ,
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
"work": Dir(
|
||||
group=coordinator,
|
||||
permissions=Permissions(
|
||||
owner=FileMode.ALL,
|
||||
group=FileMode.ALL,
|
||||
other=FileMode.NONE,
|
||||
directory=True,
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
pricing_explorer = SandboxAgent(
|
||||
name="Pricing Explorer",
|
||||
instructions="Read `pricing_packet/` and summarize commercial risk. Do not edit files.",
|
||||
run_as=explorer,
|
||||
)
|
||||
|
||||
client = UnixLocalSandboxClient()
|
||||
sandbox = await client.create(manifest=manifest)
|
||||
|
||||
async with sandbox:
|
||||
shared_run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
)
|
||||
|
||||
orchestrator = SandboxAgent(
|
||||
name="Revenue Operations Coordinator",
|
||||
instructions="Coordinate the review and write final notes to `work/`.",
|
||||
run_as=coordinator,
|
||||
tools=[
|
||||
pricing_explorer.as_tool(
|
||||
tool_name="review_pricing_packet",
|
||||
tool_description="Inspect the pricing packet and summarize commercial risk.",
|
||||
run_config=shared_run_config,
|
||||
max_turns=2,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = await Runner.run(
|
||||
orchestrator,
|
||||
"Review the pricing packet, then write final notes to `work/summary.md`.",
|
||||
run_config=shared_run_config,
|
||||
)
|
||||
```
|
||||
|
||||
Here the parent agent runs as `coordinator`, and the explorer tool-agent runs as `explorer` inside the same live sandbox session. The `pricing_packet/` entries are readable by `other` users, so the explorer can inspect them quickly, but it does not have write bits. The `work/` directory is only available to the coordinator's user/group, so the parent can write the final artifact while the explorer stays read-only.
|
||||
|
||||
When a tool-agent needs real isolation instead, give it its own sandbox `RunConfig`:
|
||||
|
||||
```python
|
||||
from docker import from_env as docker_from_env
|
||||
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
|
||||
|
||||
rollout_agent.as_tool(
|
||||
tool_name="review_rollout_risk",
|
||||
tool_description="Inspect the rollout packet and summarize implementation risk.",
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=DockerSandboxClient(docker_from_env()),
|
||||
options=DockerSandboxClientOptions(image="python:3.14-slim"),
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Use a separate sandbox when the tool-agent should mutate freely, run untrusted commands, or use a different backend/image. See [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py).
|
||||
|
||||
### Combine with local tools and MCP
|
||||
|
||||
Keep the sandbox workspace while still using ordinary tools on the same agent:
|
||||
|
||||
```python
|
||||
from agents.sandbox import SandboxAgent
|
||||
from agents.sandbox.capabilities import Shell
|
||||
|
||||
agent = SandboxAgent(
|
||||
name="Workspace reviewer",
|
||||
instructions="Inspect the workspace and call host tools when needed.",
|
||||
tools=[get_discount_approval_path],
|
||||
mcp_servers=[server],
|
||||
capabilities=[Shell()],
|
||||
)
|
||||
```
|
||||
|
||||
Use this when workspace inspection is only one part of the agent's job. See [examples/sandbox/sandbox_agent_with_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agent_with_tools.py).
|
||||
|
||||
## Memory
|
||||
|
||||
Use the `Memory` capability when future sandbox-agent runs should learn from prior runs. Memory is separate from the SDK's conversational `Session` memory: it distills lessons into files inside the sandbox workspace, then later runs can read those files.
|
||||
|
||||
See [Agent memory](memory.md) for setup, read/generate behavior, multi-turn conversations, and layout isolation.
|
||||
|
||||
## Composition patterns
|
||||
|
||||
Once the single-agent pattern is clear, the next design question is where the sandbox boundary belongs in a larger system.
|
||||
|
||||
Sandbox agents still compose with the rest of the SDK:
|
||||
|
||||
- [Handoffs](../handoffs.md): hand document-heavy work from a non-sandbox intake agent into a sandbox reviewer.
|
||||
- [Agents as tools](../tools.md#agents-as-tools): expose multiple sandbox agents as tools, usually by passing `run_config=RunConfig(sandbox=SandboxRunConfig(...))` on each `Agent.as_tool(...)` call so each tool gets its own sandbox boundary.
|
||||
- [MCP](../mcp.md) and normal function tools: sandbox capabilities can coexist with `mcp_servers` and ordinary Python tools.
|
||||
- [Running agents](../running_agents.md): sandbox runs still use the normal `Runner` APIs.
|
||||
|
||||
Two patterns are especially common:
|
||||
|
||||
- a non-sandbox agent hands off into a sandbox agent only for the part of the workflow that needs workspace isolation
|
||||
- an orchestrator exposes multiple sandbox agents as tools, usually with a separate sandbox `RunConfig` per `Agent.as_tool(...)` call so each tool gets its own isolated workspace
|
||||
|
||||
### Turns and sandbox runs
|
||||
|
||||
It helps to explain handoffs and agent-as-tool calls separately.
|
||||
|
||||
With a handoff, there is still one top-level run and one top-level turn loop. The active agent changes, but the run does not become nested. If a non-sandbox intake agent hands off to a sandbox reviewer, the next model call in that same run is prepared for the sandbox agent, and that sandbox agent becomes the one taking the next turn. In other words, handoffs change which agent owns the next turn of the same run. See [examples/sandbox/handoffs.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/handoffs.py).
|
||||
|
||||
With `Agent.as_tool(...)`, the relationship is different. The outer orchestrator uses one outer turn to decide to call the tool, and that tool call starts a nested run for the sandbox agent. The nested run has its own turn loop, `max_turns`, approvals, and usually its own sandbox `RunConfig`. It may finish in one nested turn or take several. From the outer orchestrator's point of view, all of that work still sits behind one tool invocation, so the nested turns do not increment the outer run's turn counter. See [examples/sandbox/sandbox_agents_as_tools.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/sandbox_agents_as_tools.py).
|
||||
|
||||
Approval behavior follows the same split:
|
||||
|
||||
- with handoffs, approvals stay on the same top-level run because the sandbox agent is now the active agent in that run
|
||||
- with `Agent.as_tool(...)`, approvals raised inside the sandbox tool-agent still surface on the outer run, but they come from stored nested run state and resume the nested sandbox run when the outer run resumes
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Quickstart](quickstart.md): get one sandbox agent running.
|
||||
- [Sandbox clients](clients.md): choose local, Docker, hosted, and mount options.
|
||||
- [Agent memory](memory.md): preserve and reuse lessons from prior sandbox runs.
|
||||
- [examples/sandbox/](https://github.com/openai/openai-agents-python/tree/main/examples/sandbox): runnable local, coding, memory, handoff, and agent-composition patterns.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Agent memory
|
||||
|
||||
Memory lets future sandbox-agent runs learn from prior runs. It is separate from the SDK's conversational [`Session`](../sessions/index.md) memory, which stores message history. Memory distills lessons from prior runs into files in the sandbox workspace.
|
||||
|
||||
!!! warning "Beta feature"
|
||||
|
||||
Sandbox agents are in beta. Expect details of the API, defaults, and supported capabilities to change before general availability, and expect more advanced features over time.
|
||||
|
||||
Memory can reduce three kinds of cost for future runs:
|
||||
|
||||
1. Agent cost: If the agent took a long time to complete a workflow, the next run should need less exploration. This can reduce token usage and time to completion.
|
||||
2. User cost: If the user corrected the agent or expressed a preference, future runs can remember that feedback. This can reduce human intervention.
|
||||
3. Context cost: If the agent completed a task before, and the user wants to build on that task, the user should not need to find the previous thread or re-type all the context. This makes task descriptions shorter.
|
||||
|
||||
See [examples/sandbox/memory.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory.py) for a complete two-run example that fixes a bug, generates memory, resumes a snapshot, and uses that memory in a follow-up verifier run. See [examples/sandbox/memory_multi_agent_multiturn.py](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/memory_multi_agent_multiturn.py) for a multi-turn, multi-agent example with separate memory layouts.
|
||||
|
||||
## Enable memory
|
||||
|
||||
Add `Memory()` as a capability to the sandbox agent.
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from agents.sandbox import LocalSnapshotSpec, SandboxAgent
|
||||
from agents.sandbox.capabilities import Filesystem, Memory, Shell
|
||||
|
||||
agent = SandboxAgent(
|
||||
name="Memory-enabled reviewer",
|
||||
instructions="Inspect the workspace and preserve useful lessons for follow-up runs.",
|
||||
capabilities=[Memory(), Filesystem(), Shell()],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_dir:
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
|
||||
)
|
||||
```
|
||||
|
||||
If read is enabled, `Memory()` requires `Shell()`, which lets the agent read and search memory files when the injected summary is not enough. When live memory update is enabled (by default), it also requires `Filesystem()`, which lets the agent update `memories/MEMORY.md` if the agent discovers stale memory or the user asks it to update memory.
|
||||
|
||||
By default, memory artifacts are stored in the sandbox workspace under `memories/`. To reuse them in a later run, preserve and reuse the whole configured memories directory by keeping the same live sandbox session or resuming from a persisted session state or snapshot; a fresh empty sandbox starts with empty memory.
|
||||
|
||||
`Memory()` enables both reading and generating memories. Use `Memory(generate=None)` for agents that should read memory but should not generate new memories: for example, an internal agent, subagent, checker, or one-off tool agent whose run doesn't add much signal. Use `Memory(read=None)` when the run should generate memory for later, but the user doesn't want the run to be influenced by existing memory.
|
||||
|
||||
## Read memory
|
||||
|
||||
Memory reads use progressive disclosure. At the start of a run, the SDK injects a small summary (`memory_summary.md`) of generally useful tips, user preferences, and available memories into the agent's developer prompt. This gives the agent enough context to decide whether prior work may be relevant.
|
||||
|
||||
When prior work looks relevant, the agent searches the configured memory index (`MEMORY.md` under `memories_dir`) for keywords from the current task. It opens the corresponding prior rollout summaries under the configured `rollout_summaries/` directory only when the task needs more detail.
|
||||
|
||||
Memory can become stale. Agents are instructed to treat memories as guidance only and trust the current environment. By default, memory reads have `live_update` enabled, so if the agent discovers stale memory, it can update the configured `MEMORY.md` in the same run. Disable live updates when the agent should read memory but not modify it during the run, for example if the run is latency sensitive.
|
||||
|
||||
## Generate memory
|
||||
|
||||
After a run finishes, the sandbox runtime appends that run segment to a conversation file. Accumulated conversation files are processed when the sandbox session closes.
|
||||
|
||||
Memory generation has two phases:
|
||||
|
||||
1. Phase 1: conversation extraction. A memory-generating model processes one accumulated conversation file and generates a conversation summary. System, developer, and reasoning content are omitted. If the conversation is too long, it is truncated to fit within the context window, with the beginning and end preserved. It also generates a raw memory extract: compact notes from the conversation that Phase 2 can consolidate.
|
||||
2. Phase 2: layout consolidation. A consolidation agent reads raw memories for one memory layout, opens conversation summaries when more evidence is needed, and extracts patterns into `MEMORY.md` and `memory_summary.md`.
|
||||
|
||||
The default workspace layout is:
|
||||
|
||||
```text
|
||||
workspace/
|
||||
├── sessions/
|
||||
│ └── <rollout-id>.jsonl
|
||||
└── memories/
|
||||
├── memory_summary.md
|
||||
├── MEMORY.md
|
||||
├── raw_memories.md (intermediate)
|
||||
├── phase_two_selection.json (intermediate)
|
||||
├── raw_memories/ (intermediate)
|
||||
│ └── <rollout-id>.md
|
||||
├── rollout_summaries/
|
||||
│ └── <rollout-id>_<slug>.md
|
||||
└── skills/
|
||||
```
|
||||
|
||||
You can configure memory generation with `MemoryGenerateConfig`:
|
||||
|
||||
```python
|
||||
from agents.sandbox import MemoryGenerateConfig
|
||||
from agents.sandbox.capabilities import Memory
|
||||
|
||||
memory = Memory(
|
||||
generate=MemoryGenerateConfig(
|
||||
max_raw_memories_for_consolidation=128,
|
||||
extra_prompt="Pay extra attention to what made the customer more satisfied or annoyed",
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Use `extra_prompt` to tell the memory generator which signals matter most for your use case, such as customer and company details for a GTM agent.
|
||||
|
||||
If recent raw memories exceed `max_raw_memories_for_consolidation` (defaults to 256), Phase 2 keeps only memories from the newest conversations and removes older ones. Recency is based on the last time the conversation is updated. This forgetting mechanism helps memories reflect the newest environment.
|
||||
|
||||
## Multi-turn conversations
|
||||
|
||||
For multi-turn sandbox chats, use the normal SDK `Session` together with the same live sandbox session:
|
||||
|
||||
```python
|
||||
from agents import Runner, SQLiteSession
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxRunConfig
|
||||
|
||||
conversation_session = SQLiteSession("gtm-q2-pipeline-review")
|
||||
sandbox = await client.create(manifest=agent.default_manifest)
|
||||
|
||||
async with sandbox:
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name="GTM memory example",
|
||||
)
|
||||
await Runner.run(
|
||||
agent,
|
||||
"Analyze data/leads.csv and identify one promising GTM segment.",
|
||||
session=conversation_session,
|
||||
run_config=run_config,
|
||||
)
|
||||
await Runner.run(
|
||||
agent,
|
||||
"Using that analysis, write a short outreach hypothesis.",
|
||||
session=conversation_session,
|
||||
run_config=run_config,
|
||||
)
|
||||
```
|
||||
|
||||
Both runs append to one memory conversation file because they pass the same SDK conversation session (`session=conversation_session`) and therefore share the same `session.session_id`. This is different from the sandbox (`sandbox`), which identifies the live workspace and is not used as the memory conversation ID. Phase 1 sees the accumulated conversation when the sandbox session closes, so it can extract memory from the whole exchange instead of two isolated turns.
|
||||
|
||||
If you want multiple `Runner.run(...)` calls to become one memory conversation, pass a stable identifier across those calls. When memory associates a run with a conversation, it resolves in this order:
|
||||
|
||||
1. `conversation_id`, when you pass one to `Runner.run(...)`
|
||||
2. `session.session_id`, when you pass an SDK `Session` such as `SQLiteSession`
|
||||
3. `RunConfig.group_id`, when neither of the above is present
|
||||
4. A generated per-run ID, when no stable identifier is present
|
||||
|
||||
## Use different layouts to isolate memory for different agents
|
||||
|
||||
Memory isolation is based on `MemoryLayoutConfig`, not on agent name. Agents with the same layout and the same memory conversation ID share one memory conversation and one consolidated memory. Agents with different layouts keep separate rollout files, raw memories, `MEMORY.md`, and `memory_summary.md`, even when they share the same sandbox workspace.
|
||||
|
||||
Use separate layouts when multiple agents share one sandbox but should not share memory:
|
||||
|
||||
```python
|
||||
from agents import SQLiteSession
|
||||
from agents.sandbox import MemoryLayoutConfig, SandboxAgent
|
||||
from agents.sandbox.capabilities import Filesystem, Memory, Shell
|
||||
|
||||
gtm_agent = SandboxAgent(
|
||||
name="GTM reviewer",
|
||||
instructions="Analyze GTM workspace data and write concise recommendations.",
|
||||
capabilities=[
|
||||
Memory(
|
||||
layout=MemoryLayoutConfig(
|
||||
memories_dir="memories/gtm",
|
||||
sessions_dir="sessions/gtm",
|
||||
)
|
||||
),
|
||||
Filesystem(),
|
||||
Shell(),
|
||||
],
|
||||
)
|
||||
|
||||
engineering_agent = SandboxAgent(
|
||||
name="Engineering reviewer",
|
||||
instructions="Inspect engineering workspaces and summarize fixes and risks.",
|
||||
capabilities=[
|
||||
Memory(
|
||||
layout=MemoryLayoutConfig(
|
||||
memories_dir="memories/engineering",
|
||||
sessions_dir="sessions/engineering",
|
||||
)
|
||||
),
|
||||
Filesystem(),
|
||||
Shell(),
|
||||
],
|
||||
)
|
||||
|
||||
gtm_session = SQLiteSession("gtm-q2-pipeline-review")
|
||||
engineering_session = SQLiteSession("eng-invoice-test-fix")
|
||||
```
|
||||
|
||||
This prevents GTM analysis from being consolidated into engineering bug-fix memory, and vice versa.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
# Make the examples/sandbox directory a package for tooling consistency.
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
|
||||
from agents.sandbox.entries import File
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
Backend = Literal["docker", "modal"]
|
||||
WorkspacePersistenceMode = Literal["tar", "snapshot_filesystem", "snapshot_directory"]
|
||||
|
||||
DEFAULT_QUESTION = "Summarize this sandbox project in 2 sentences."
|
||||
DEFAULT_BACKEND: Backend = "docker"
|
||||
DEFAULT_MODAL_APP_NAME = "openai-agents-python-sandbox-example"
|
||||
DEFAULT_MODAL_WORKSPACE_PERSISTENCE: WorkspacePersistenceMode = "tar"
|
||||
|
||||
|
||||
def _stream_event_banner(event_name: str) -> str | None:
|
||||
if event_name == "tool_called":
|
||||
return "[tool call] shell"
|
||||
if event_name == "tool_output":
|
||||
return "[tool output] shell"
|
||||
return None
|
||||
|
||||
|
||||
def _build_manifest(backend: Backend) -> Manifest:
|
||||
backend_label = "Docker" if backend == "docker" else "Modal"
|
||||
return Manifest(
|
||||
entries={
|
||||
"README.md": File(
|
||||
content=(
|
||||
b"# Demo Project\n\n"
|
||||
+ (
|
||||
f"This sandbox contains a tiny demo project for the {backend_label} "
|
||||
"sandbox runner.\n"
|
||||
).encode()
|
||||
+ b"The goal is to show how Runner can prepare a sandbox workspace.\n"
|
||||
)
|
||||
),
|
||||
"src/app.py": File(
|
||||
content=b'def greet(name: str) -> str:\n return f"Hello, {name}!"\n'
|
||||
),
|
||||
"docs/notes.md": File(
|
||||
content=(
|
||||
b"# Notes\n\n"
|
||||
b"- The example is intentionally minimal.\n"
|
||||
b"- The model should inspect files through the shell tool.\n"
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_agent(*, model: str, manifest: Manifest, backend: Backend) -> SandboxAgent:
|
||||
backend_label = "Docker" if backend == "docker" else "Modal"
|
||||
return SandboxAgent(
|
||||
name=f"{backend_label} Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the project before answering, "
|
||||
"and keep the response concise. "
|
||||
"Do not guess file names like package.json or pyproject.toml. "
|
||||
"This demo intentionally contains a tiny workspace."
|
||||
),
|
||||
# `default_manifest` tells the sandbox agent which workspace it should expect.
|
||||
default_manifest=manifest,
|
||||
# `WorkspaceShellCapability()` exposes one shell tool so the model can inspect files.
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
# `tool_choice="required"` makes the demo more deterministic by forcing the model
|
||||
# to look at the workspace instead of answering from prior assumptions.
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
|
||||
def _require_modal_dependency() -> tuple[Any, Any]:
|
||||
try:
|
||||
from agents.extensions.sandbox import ModalSandboxClient, ModalSandboxClientOptions
|
||||
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
||||
raise SystemExit(
|
||||
"Modal-backed runs require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra modal"
|
||||
) from exc
|
||||
|
||||
return ModalSandboxClient, ModalSandboxClientOptions
|
||||
|
||||
|
||||
def _path_resolves_to(path: str, target: Path) -> bool:
|
||||
try:
|
||||
return Path(path or ".").resolve() == target
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _import_docker_from_env() -> Any:
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
original_sys_path = sys.path[:]
|
||||
try:
|
||||
sys.path = [entry for entry in sys.path if not _path_resolves_to(entry, script_dir)]
|
||||
from docker import from_env as docker_from_env # type: ignore[import-untyped]
|
||||
except Exception as exc: # pragma: no cover - import path depends on local Docker setup
|
||||
raise SystemExit(
|
||||
f"Docker-backed runs failed to import the Docker SDK: {exc}\n"
|
||||
"Install the repo dependencies with: make sync\n"
|
||||
"If you are running this file directly, try:\n"
|
||||
"uv run python -m examples.sandbox.basic --backend docker"
|
||||
) from exc
|
||||
finally:
|
||||
sys.path = original_sys_path
|
||||
|
||||
return docker_from_env
|
||||
|
||||
|
||||
def _require_docker_dependency() -> tuple[Any, Any, Any]:
|
||||
docker_from_env = _import_docker_from_env()
|
||||
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
|
||||
|
||||
return docker_from_env, DockerSandboxClient, DockerSandboxClientOptions
|
||||
|
||||
|
||||
async def _create_session(
|
||||
*,
|
||||
backend: Backend,
|
||||
manifest: Manifest,
|
||||
agent: SandboxAgent,
|
||||
):
|
||||
if backend == "docker":
|
||||
docker_from_env, DockerSandboxClient, DockerSandboxClientOptions = (
|
||||
_require_docker_dependency()
|
||||
)
|
||||
client = DockerSandboxClient(docker_from_env())
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE),
|
||||
)
|
||||
return client, sandbox
|
||||
|
||||
ModalSandboxClient, ModalSandboxClientOptions = _require_modal_dependency()
|
||||
client = ModalSandboxClient()
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
options=ModalSandboxClientOptions(
|
||||
app_name=DEFAULT_MODAL_APP_NAME,
|
||||
workspace_persistence=DEFAULT_MODAL_WORKSPACE_PERSISTENCE,
|
||||
),
|
||||
)
|
||||
return client, sandbox
|
||||
|
||||
|
||||
async def main(
|
||||
model: str,
|
||||
question: str,
|
||||
backend: Backend,
|
||||
) -> None:
|
||||
manifest = _build_manifest(backend)
|
||||
agent = _build_agent(model=model, manifest=manifest, backend=backend)
|
||||
client, sandbox = await _create_session(
|
||||
backend=backend,
|
||||
manifest=manifest,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
await sandbox.start()
|
||||
print(await sandbox.ls("."))
|
||||
|
||||
try:
|
||||
# `async with sandbox` keeps the example on the public session lifecycle API.
|
||||
# `Runner` reuses the already-running session without starting it a second time.
|
||||
async with sandbox:
|
||||
# `Runner.run_streamed()` drives the model and yields text and tool events in real time.
|
||||
result = Runner.run_streamed(
|
||||
agent,
|
||||
question,
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name=f"{backend.title()} sandbox example",
|
||||
),
|
||||
)
|
||||
saw_text_delta = False
|
||||
saw_any_text = False
|
||||
|
||||
# The stream contains raw text deltas from the assistant plus structured tool events.
|
||||
async for event in result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
saw_any_text = True
|
||||
continue
|
||||
|
||||
if event.type != "run_item_stream_event":
|
||||
continue
|
||||
|
||||
banner = _stream_event_banner(event.name)
|
||||
if banner is not None:
|
||||
if saw_text_delta:
|
||||
print()
|
||||
saw_text_delta = False
|
||||
print(banner)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
if not saw_any_text:
|
||||
print(result.final_output)
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
default=DEFAULT_BACKEND,
|
||||
choices=["docker", "modal"],
|
||||
help="Sandbox backend to use for this example.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(
|
||||
main(
|
||||
args.model,
|
||||
args.question,
|
||||
cast(Backend, args.backend),
|
||||
)
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Docker-specific sandbox examples.
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Start here if you are new to Docker-backed sandbox examples.
|
||||
|
||||
This file keeps the flow explicit:
|
||||
|
||||
1. Build a manifest for the files that should appear in the sandbox workspace.
|
||||
2. Create a sandbox agent that can inspect that workspace through one shell tool.
|
||||
3. Start a Docker-backed sandbox session, stream the run, and print what happens.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from docker import from_env as docker_from_env # type: ignore[import-untyped]
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.config import DEFAULT_PYTHON_SANDBOX_IMAGE
|
||||
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest, tool_call_name
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
DEFAULT_QUESTION = "Summarize this sandbox project in 2 sentences."
|
||||
MAX_STREAM_TOOL_OUTPUT_CHARS = 2000
|
||||
|
||||
|
||||
def _format_tool_arguments(raw_item: object) -> str | None:
|
||||
arguments = raw_item.get("arguments") if isinstance(raw_item, dict) else None
|
||||
if isinstance(arguments, str) and arguments:
|
||||
return arguments
|
||||
|
||||
action = raw_item.get("action") if isinstance(raw_item, dict) else None
|
||||
commands = action.get("commands") if isinstance(action, dict) else None
|
||||
if isinstance(commands, list):
|
||||
return "; ".join(command for command in commands if isinstance(command, str))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_tool_call(raw_item: object) -> str:
|
||||
name = tool_call_name(raw_item) or "tool"
|
||||
arguments = _format_tool_arguments(raw_item)
|
||||
if arguments:
|
||||
return f"[tool call] {name}: {arguments}"
|
||||
return f"[tool call] {name}"
|
||||
|
||||
|
||||
def _format_tool_output(output: object) -> str:
|
||||
output_text = str(output)
|
||||
if len(output_text) > MAX_STREAM_TOOL_OUTPUT_CHARS:
|
||||
output_text = f"{output_text[:MAX_STREAM_TOOL_OUTPUT_CHARS]}..."
|
||||
if output_text:
|
||||
return f"[tool output]\n{output_text}"
|
||||
return "[tool output]"
|
||||
|
||||
|
||||
async def main(model: str, question: str) -> None:
|
||||
# A manifest is the starting file tree for the sandbox workspace.
|
||||
# Each key is a path inside the workspace and each value is the file content.
|
||||
# `text_manifest()` keeps small text examples readable by hiding the bytes boilerplate.
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Demo Project\n\n"
|
||||
"This sandbox contains a tiny demo project for the sandbox runner.\n"
|
||||
"The goal is to show how Runner can prepare a Docker-backed workspace.\n"
|
||||
),
|
||||
"src/app.py": 'def greet(name: str) -> str:\n return f"Hello, {name}!"\n',
|
||||
"docs/notes.md": (
|
||||
"# Notes\n\n"
|
||||
"- The example is intentionally minimal.\n"
|
||||
"- The model should inspect files through the shell tool.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
agent = SandboxAgent(
|
||||
name="Docker Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the project before answering, "
|
||||
"and keep the response concise. "
|
||||
"Do not guess file names like package.json or pyproject.toml. "
|
||||
"This demo intentionally contains a tiny workspace."
|
||||
),
|
||||
# `default_manifest` tells the sandbox agent which workspace it should expect.
|
||||
default_manifest=manifest,
|
||||
# `WorkspaceShellCapability()` exposes one shell tool so the model can inspect files.
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
# `tool_choice="required"` makes the demo more deterministic by forcing the model
|
||||
# to look at the workspace instead of answering from prior assumptions.
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
# The Docker client owns the container lifecycle for the sandbox session.
|
||||
docker_client = DockerSandboxClient(docker_from_env())
|
||||
|
||||
# `create()` allocates a fresh sandbox session backed by a Docker container.
|
||||
# We pass the same manifest here so the container knows which files to materialize.
|
||||
sandbox = await docker_client.create(
|
||||
manifest=manifest,
|
||||
options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE),
|
||||
)
|
||||
try:
|
||||
# `async with sandbox` keeps the example on the public session lifecycle API.
|
||||
# `Runner` reuses the already-running session without starting it a second time.
|
||||
async with sandbox:
|
||||
# `Runner.run_streamed()` drives the model and yields text and tool events in real time.
|
||||
result = Runner.run_streamed(
|
||||
agent,
|
||||
question,
|
||||
run_config=RunConfig(sandbox=SandboxRunConfig(session=sandbox)),
|
||||
)
|
||||
saw_text_delta = False
|
||||
saw_any_text = False
|
||||
|
||||
# The stream contains raw text deltas from the assistant plus structured tool events.
|
||||
async for event in result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
saw_any_text = True
|
||||
continue
|
||||
|
||||
if event.type != "run_item_stream_event":
|
||||
continue
|
||||
|
||||
if event.name == "tool_called" and event.item.type == "tool_call_item":
|
||||
if saw_text_delta:
|
||||
print()
|
||||
saw_text_delta = False
|
||||
print(_format_tool_call(event.item.raw_item))
|
||||
elif event.name == "tool_output" and event.item.type == "tool_call_output_item":
|
||||
if saw_text_delta:
|
||||
print()
|
||||
saw_text_delta = False
|
||||
print(_format_tool_output(event.item.output))
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
if not saw_any_text:
|
||||
print(result.final_output)
|
||||
finally:
|
||||
# The client still owns deleting the underlying Docker container.
|
||||
await docker_client.delete(sandbox)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.model, args.question))
|
||||
@@ -0,0 +1 @@
|
||||
# Docker mount smoke-test examples.
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
|
||||
|
||||
from agents.sandbox.entries import (
|
||||
AzureBlobMount,
|
||||
DockerVolumeMountStrategy,
|
||||
FuseMountPattern,
|
||||
InContainerMountStrategy,
|
||||
RcloneMountPattern,
|
||||
)
|
||||
from examples.sandbox.docker.mounts.mount_smoke import (
|
||||
MountSmokeCase,
|
||||
require_env,
|
||||
run_mount_smoke_test,
|
||||
)
|
||||
|
||||
|
||||
def _mount_cases() -> list[MountSmokeCase]:
|
||||
account = require_env("AZURE_STORAGE_ACCOUNT")
|
||||
container = require_env("AZURE_STORAGE_CONTAINER")
|
||||
endpoint = os.getenv("AZURE_STORAGE_ENDPOINT")
|
||||
identity_client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
account_key = os.getenv("AZURE_STORAGE_ACCOUNT_KEY")
|
||||
|
||||
return [
|
||||
MountSmokeCase(
|
||||
name="docker_volume/rclone",
|
||||
mount_dir="azure-docker-volume-rclone",
|
||||
mount=AzureBlobMount(
|
||||
account=account,
|
||||
container=container,
|
||||
endpoint=endpoint,
|
||||
identity_client_id=identity_client_id,
|
||||
account_key=account_key,
|
||||
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
MountSmokeCase(
|
||||
name="in_container/rclone",
|
||||
mount_dir="azure-in-container-rclone",
|
||||
mount=AzureBlobMount(
|
||||
account=account,
|
||||
container=container,
|
||||
endpoint=endpoint,
|
||||
identity_client_id=identity_client_id,
|
||||
account_key=account_key,
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
MountSmokeCase(
|
||||
name="in_container/fuse",
|
||||
mount_dir="azure-in-container-fuse",
|
||||
mount=AzureBlobMount(
|
||||
account=account,
|
||||
container=container,
|
||||
endpoint=endpoint,
|
||||
identity_client_id=identity_client_id,
|
||||
account_key=account_key,
|
||||
mount_strategy=InContainerMountStrategy(pattern=FuseMountPattern()),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await run_mount_smoke_test(
|
||||
provider="azure",
|
||||
agent_name="Azure Blob Mount Smoke Test",
|
||||
mount_cases=_mount_cases(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
|
||||
|
||||
from agents.sandbox.entries import (
|
||||
DockerVolumeMountStrategy,
|
||||
GCSMount,
|
||||
InContainerMountStrategy,
|
||||
MountpointMountPattern,
|
||||
RcloneMountPattern,
|
||||
)
|
||||
from examples.sandbox.docker.mounts.mount_smoke import (
|
||||
MountSmokeCase,
|
||||
require_env,
|
||||
run_mount_smoke_test,
|
||||
)
|
||||
|
||||
|
||||
def _mount_cases() -> list[MountSmokeCase]:
|
||||
bucket = require_env("GCS_MOUNT_BUCKET")
|
||||
access_id = os.getenv("GCS_ACCESS_ID")
|
||||
secret_access_key = os.getenv("GCS_SECRET_ACCESS_KEY")
|
||||
prefix = os.getenv("GCS_MOUNT_PREFIX")
|
||||
region = os.getenv("GCS_REGION")
|
||||
endpoint_url = os.getenv("GCS_ENDPOINT_URL")
|
||||
service_account_file = os.getenv("GCS_SERVICE_ACCOUNT_FILE")
|
||||
service_account_credentials = os.getenv("GCS_SERVICE_ACCOUNT_CREDENTIALS")
|
||||
access_token = os.getenv("GCS_ACCESS_TOKEN")
|
||||
|
||||
return [
|
||||
MountSmokeCase(
|
||||
name="docker_volume/rclone",
|
||||
mount_dir="gcs-docker-volume-rclone",
|
||||
mount=GCSMount(
|
||||
bucket=bucket,
|
||||
access_id=access_id,
|
||||
secret_access_key=secret_access_key,
|
||||
prefix=prefix,
|
||||
region=region,
|
||||
endpoint_url=endpoint_url,
|
||||
service_account_file=service_account_file,
|
||||
service_account_credentials=service_account_credentials,
|
||||
access_token=access_token,
|
||||
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
MountSmokeCase(
|
||||
name="in_container/rclone",
|
||||
mount_dir="gcs-in-container-rclone",
|
||||
mount=GCSMount(
|
||||
bucket=bucket,
|
||||
access_id=access_id,
|
||||
secret_access_key=secret_access_key,
|
||||
prefix=prefix,
|
||||
region=region,
|
||||
endpoint_url=endpoint_url,
|
||||
service_account_file=service_account_file,
|
||||
service_account_credentials=service_account_credentials,
|
||||
access_token=access_token,
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
MountSmokeCase(
|
||||
name="in_container/mountpoint",
|
||||
mount_dir="gcs-in-container-mountpoint",
|
||||
mount=GCSMount(
|
||||
bucket=bucket,
|
||||
access_id=access_id,
|
||||
secret_access_key=secret_access_key,
|
||||
prefix=prefix,
|
||||
region=region,
|
||||
endpoint_url=endpoint_url,
|
||||
service_account_file=service_account_file,
|
||||
service_account_credentials=service_account_credentials,
|
||||
access_token=access_token,
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await run_mount_smoke_test(
|
||||
provider="gcs",
|
||||
agent_name="GCS Mount Smoke Test",
|
||||
mount_cases=_mount_cases(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import docker # type: ignore[import-untyped]
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.entries import Mount
|
||||
from agents.sandbox.errors import MountCommandError
|
||||
from agents.sandbox.sandboxes.docker import (
|
||||
DockerSandboxClient,
|
||||
DockerSandboxClientOptions,
|
||||
)
|
||||
from agents.sandbox.session.sandbox_session import SandboxSession
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
IMAGE = "agents-sandbox-docker-mount-example:latest"
|
||||
DOCKERFILE = Path(__file__).resolve().parent.parent / "Dockerfile.mount"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MountSmokeCase:
|
||||
"""One mount target to verify inside a shared Docker sandbox session."""
|
||||
|
||||
name: str
|
||||
mount_dir: str
|
||||
mount: Mount
|
||||
|
||||
|
||||
def require_env(name: str) -> str:
|
||||
"""Return a required environment variable or stop with a clear message."""
|
||||
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise SystemExit(f"Missing required environment variable: {name}")
|
||||
return value
|
||||
|
||||
|
||||
def ensure_mount_image() -> None:
|
||||
"""Build the Docker image with the in-container mount CLIs if it is missing."""
|
||||
|
||||
docker_client = docker.from_env()
|
||||
try:
|
||||
docker_client.images.get(IMAGE)
|
||||
return
|
||||
except docker.errors.ImageNotFound:
|
||||
pass
|
||||
|
||||
print(f"building {IMAGE} from {DOCKERFILE.name}...")
|
||||
docker_client.images.build(
|
||||
path=str(DOCKERFILE.parent),
|
||||
dockerfile=DOCKERFILE.name,
|
||||
tag=IMAGE,
|
||||
rm=True,
|
||||
)
|
||||
|
||||
|
||||
def build_agent(name: str, manifest: Manifest) -> SandboxAgent:
|
||||
"""Create the minimal shell-only agent used by these mount smoke tests."""
|
||||
|
||||
return SandboxAgent(
|
||||
name=name,
|
||||
model=os.getenv("OPENAI_MODEL", "gpt-5.4"),
|
||||
instructions=(
|
||||
"Use the shell tool only. Write the requested exact content to the requested exact "
|
||||
"path, read the file back with cat, and then reply with only `done`."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
|
||||
async def _check_case(
|
||||
sandbox: SandboxSession,
|
||||
agent: SandboxAgent,
|
||||
provider: str,
|
||||
mount_case: MountSmokeCase,
|
||||
) -> None:
|
||||
key = f"docker-{provider}-mount-example-{mount_case.mount_dir}-{uuid.uuid4().hex}.txt"
|
||||
path = Path("/workspace") / mount_case.mount_dir / key
|
||||
expected = f"hello from {mount_case.name} {uuid.uuid4().hex}"
|
||||
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
(
|
||||
f"Write exactly this content to {path} with `printf %s`, not `echo`: {expected}\n"
|
||||
f"Then read {path} back with cat."
|
||||
),
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name=f"Docker {provider} mount smoke test ({mount_case.name})",
|
||||
),
|
||||
)
|
||||
print(result.final_output)
|
||||
|
||||
read_back = await sandbox.read(path)
|
||||
actual = read_back.read()
|
||||
if not isinstance(actual, bytes):
|
||||
raise TypeError(f"Expected bytes from session.read(), got {type(actual)!r}")
|
||||
|
||||
actual_text = actual.decode("utf-8")
|
||||
if actual_text == f"{expected}\n":
|
||||
actual_text = expected
|
||||
|
||||
assert actual_text == expected, f"read back {actual!r}, expected {expected!r}"
|
||||
print(f"{mount_case.name}: ok")
|
||||
|
||||
|
||||
async def run_mount_smoke_test(
|
||||
*,
|
||||
provider: str,
|
||||
agent_name: str,
|
||||
mount_cases: Sequence[MountSmokeCase],
|
||||
) -> None:
|
||||
"""Start one Docker sandbox session and verify read/write on every mount target."""
|
||||
|
||||
ensure_mount_image()
|
||||
|
||||
manifest = Manifest(
|
||||
entries={mount_case.mount_dir: mount_case.mount for mount_case in mount_cases},
|
||||
)
|
||||
agent = build_agent(agent_name, manifest)
|
||||
client = DockerSandboxClient(docker.from_env())
|
||||
|
||||
try:
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
options=DockerSandboxClientOptions(image=IMAGE),
|
||||
)
|
||||
except docker.errors.NotFound as exc:
|
||||
if 'plugin "rclone" not found' in str(exc):
|
||||
raise SystemExit("rclone Docker volume plugin not found") from exc
|
||||
raise
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
except MountCommandError as exc:
|
||||
print(f"mount command: {exc.context.get('command')}")
|
||||
print(f"mount stderr: {exc.context.get('stderr')}")
|
||||
raise
|
||||
|
||||
try:
|
||||
for mount_case in mount_cases:
|
||||
await _check_case(sandbox, agent, provider, mount_case)
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Smoke-test an Amazon S3 Files file-system mount in Docker.
|
||||
|
||||
Required:
|
||||
|
||||
S3_FILES_FILE_SYSTEM_ID=fs-...
|
||||
|
||||
Common optional settings:
|
||||
|
||||
S3_FILES_MOUNT_TARGET_IP=10.0.0.123
|
||||
AWS_REGION=us-east-1
|
||||
S3_FILES_ACCESS_POINT=fsap-...
|
||||
S3_FILES_SUBPATH=/path/in/file-system
|
||||
|
||||
Example:
|
||||
|
||||
S3_FILES_FILE_SYSTEM_ID=fs-... \
|
||||
S3_FILES_MOUNT_TARGET_IP=10.0.0.123 \
|
||||
AWS_REGION=us-east-1 \
|
||||
uv run python examples/sandbox/docker/mounts/s3_files_mount_read_write.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
|
||||
|
||||
from agents.sandbox.entries import (
|
||||
InContainerMountStrategy,
|
||||
S3FilesMount,
|
||||
S3FilesMountPattern,
|
||||
)
|
||||
from examples.sandbox.docker.mounts.mount_smoke import (
|
||||
MountSmokeCase,
|
||||
require_env,
|
||||
run_mount_smoke_test,
|
||||
)
|
||||
|
||||
|
||||
def _mount_cases() -> list[MountSmokeCase]:
|
||||
file_system_id = require_env("S3_FILES_FILE_SYSTEM_ID")
|
||||
return [
|
||||
MountSmokeCase(
|
||||
name="in_container/s3files",
|
||||
mount_dir="s3-files-in-container",
|
||||
mount=S3FilesMount(
|
||||
file_system_id=file_system_id,
|
||||
subpath=os.getenv("S3_FILES_SUBPATH"),
|
||||
mount_target_ip=os.getenv("S3_FILES_MOUNT_TARGET_IP"),
|
||||
access_point=os.getenv("S3_FILES_ACCESS_POINT"),
|
||||
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=S3FilesMountPattern()),
|
||||
read_only=False,
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await run_mount_smoke_test(
|
||||
provider="s3-files",
|
||||
agent_name="S3 Files Mount Smoke Test",
|
||||
mount_cases=_mount_cases(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
|
||||
|
||||
from agents.sandbox.entries import (
|
||||
DockerVolumeMountStrategy,
|
||||
InContainerMountStrategy,
|
||||
MountpointMountPattern,
|
||||
RcloneMountPattern,
|
||||
S3Mount,
|
||||
)
|
||||
from examples.sandbox.docker.mounts.mount_smoke import (
|
||||
MountSmokeCase,
|
||||
require_env,
|
||||
run_mount_smoke_test,
|
||||
)
|
||||
|
||||
|
||||
def _mount_cases() -> list[MountSmokeCase]:
|
||||
bucket = require_env("S3_MOUNT_BUCKET")
|
||||
return [
|
||||
MountSmokeCase(
|
||||
name="docker_volume/rclone",
|
||||
mount_dir="s3-docker-volume-rclone",
|
||||
mount=S3Mount(
|
||||
bucket=bucket,
|
||||
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
session_token=os.getenv("AWS_SESSION_TOKEN"),
|
||||
prefix=os.getenv("S3_MOUNT_PREFIX"),
|
||||
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
|
||||
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
|
||||
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
MountSmokeCase(
|
||||
name="in_container/rclone",
|
||||
mount_dir="s3-in-container-rclone",
|
||||
mount=S3Mount(
|
||||
bucket=bucket,
|
||||
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
session_token=os.getenv("AWS_SESSION_TOKEN"),
|
||||
prefix=os.getenv("S3_MOUNT_PREFIX"),
|
||||
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
|
||||
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
MountSmokeCase(
|
||||
name="in_container/mountpoint",
|
||||
mount_dir="s3-in-container-mountpoint",
|
||||
mount=S3Mount(
|
||||
bucket=bucket,
|
||||
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
session_token=os.getenv("AWS_SESSION_TOKEN"),
|
||||
prefix=os.getenv("S3_MOUNT_PREFIX"),
|
||||
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
|
||||
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
|
||||
mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()),
|
||||
read_only=False,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await run_mount_smoke_test(
|
||||
provider="s3",
|
||||
agent_name="S3 Mount Smoke Test",
|
||||
mount_cases=_mount_cases(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1 @@
|
||||
# Runnable coding-task assets for the sandbox agents docs.
|
||||
@@ -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))
|
||||
@@ -0,0 +1,6 @@
|
||||
# Credit Note Example Repo
|
||||
|
||||
This tiny repo exists to support `examples/sandbox/docs/coding_task.py`.
|
||||
|
||||
The task is intentionally small so a sandbox coding agent can inspect the repo,
|
||||
apply a minimal patch, and prove the fix with one targeted shell test command.
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
customer="$1"
|
||||
amount="$2"
|
||||
|
||||
printf 'Credit note for %s: -$%s debit.\n' "$customer" "$amount"
|
||||
@@ -0,0 +1,15 @@
|
||||
# Task
|
||||
|
||||
`credit_note.sh` formats a credit note incorrectly:
|
||||
|
||||
- It prints a debit label instead of a credit label.
|
||||
- It preserves the sign instead of always showing the credited amount as positive.
|
||||
|
||||
Use the smallest correct fix, then run this exact verification command from the `repo/` directory:
|
||||
|
||||
`sh tests/test_credit_note.sh`
|
||||
|
||||
If you use `apply_patch`, the patch paths must still be relative to the sandbox workspace root.
|
||||
That means the file paths should be `repo/credit_note.sh` and `repo/tests/test_credit_note.sh`.
|
||||
|
||||
Do not change the test expectations.
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
actual_positive="$(sh credit_note.sh Northwind 12.50)"
|
||||
if [ "$actual_positive" != 'Credit note for Northwind: $12.50 credit.' ]; then
|
||||
printf 'expected positive case to pass, got: %s\n' "$actual_positive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
actual_negative="$(sh credit_note.sh Northwind -12.50)"
|
||||
if [ "$actual_negative" != 'Credit note for Northwind: $12.50 credit.' ]; then
|
||||
printf 'expected negative case to pass, got: %s\n' "$actual_negative" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '2 passed\n'
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: credit-note-fixer
|
||||
description: Fix the tiny credit-note formatting bug and rerun the exact targeted test command.
|
||||
---
|
||||
|
||||
# Credit Note Fixer
|
||||
|
||||
Follow this workflow:
|
||||
|
||||
1. Read `repo/task.md`.
|
||||
2. Inspect `repo/credit_note.sh` and `repo/tests/test_credit_note.sh`.
|
||||
3. Make the smallest correct change that keeps the output label as `credit` and the amount positive.
|
||||
If you use `apply_patch`, use workspace-root-relative paths such as
|
||||
`repo/credit_note.sh` and `repo/tests/test_credit_note.sh`.
|
||||
4. Run exactly `sh tests/test_credit_note.sh` from `repo/`.
|
||||
5. In the final answer, summarize the bug, the fix, and the exact verification command.
|
||||
@@ -0,0 +1,378 @@
|
||||
# Cloud Sandbox Extension Examples
|
||||
|
||||
These examples are for manual verification of the cloud sandbox backends that
|
||||
live under `agents.extensions.sandbox`.
|
||||
|
||||
They intentionally keep the flow simple:
|
||||
|
||||
1. Build a tiny manifest in memory.
|
||||
2. Create a `SandboxAgent` that inspects that workspace through one shell tool.
|
||||
3. Run the agent against E2B, Modal, Daytona, Cloudflare, Runloop, Blaxel, or Vercel.
|
||||
|
||||
All of these examples require `OPENAI_API_KEY`, because they call the model through the normal
|
||||
`Runner` path. Each cloud backend also needs its own provider credentials.
|
||||
|
||||
## E2B
|
||||
|
||||
### Setup
|
||||
|
||||
Install the repo extra:
|
||||
|
||||
```bash
|
||||
uv sync --extra e2b
|
||||
```
|
||||
|
||||
Create an E2B account, create an API key, and export it as `E2B_API_KEY`.
|
||||
The official setup docs are:
|
||||
|
||||
- <https://e2b.dev/docs/api-key>
|
||||
- <https://e2b.dev/docs/quickstart>
|
||||
|
||||
Export the required environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export E2B_API_KEY=...
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/extensions/e2b_runner.py --stream
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--sandbox-type e2b_code_interpreter`
|
||||
- `--template <template-name>`
|
||||
- `--timeout 300`
|
||||
- `--pause-on-exit`
|
||||
|
||||
The example defaults to `e2b`, which provides a bash-style interface.
|
||||
Use `e2b_code_interpreter` for a Jupyter-style interface.
|
||||
|
||||
## Modal
|
||||
|
||||
If you want the same explicit session lifecycle shown in
|
||||
`examples/sandbox/basic.py`, that example now accepts
|
||||
`--backend modal` and reuses the same streamed tool-output flow:
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/basic.py \
|
||||
--backend modal
|
||||
```
|
||||
|
||||
The dedicated script below stays as the smaller extension-specific example.
|
||||
|
||||
### Setup
|
||||
|
||||
Install the repo extra:
|
||||
|
||||
```bash
|
||||
uv sync --extra modal
|
||||
```
|
||||
|
||||
Authenticate Modal with either CLI token setup or environment variables. The
|
||||
official references are:
|
||||
|
||||
- <https://modal.com/docs/reference/cli/token>
|
||||
- <https://modal.com/docs/reference/modal.config>
|
||||
- <https://modal.com/docs/guide/sandbox>
|
||||
|
||||
If you want to configure credentials directly from the CLI:
|
||||
|
||||
```bash
|
||||
uv run modal token set --token-id <token-id> --token-secret <token-secret>
|
||||
```
|
||||
|
||||
Or export environment variables for the current shell:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export MODAL_TOKEN_ID=...
|
||||
export MODAL_TOKEN_SECRET=...
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/extensions/modal_runner.py \
|
||||
--app-name openai-agents-python-sandbox-example \
|
||||
--stream
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--workspace-persistence tar`
|
||||
- `--workspace-persistence snapshot_filesystem`
|
||||
- `--workspace-persistence snapshot_directory`
|
||||
- `--sandbox-create-timeout-s 60`
|
||||
- `--native-cloud-bucket-secret-name my-modal-secret`
|
||||
|
||||
`app_name` is required by `ModalSandboxClientOptions`, so the example makes it
|
||||
an explicit CLI flag instead of hiding it.
|
||||
|
||||
Modal sandboxes also support native cloud bucket mounts through
|
||||
`ModalCloudBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated
|
||||
`GCSMount`.
|
||||
|
||||
For native cloud bucket testing, you can either export raw credential
|
||||
environment variables or pass `--native-cloud-bucket-secret-name` to reuse an
|
||||
existing named Modal Secret instead.
|
||||
|
||||
## Cloudflare
|
||||
|
||||
### Setup
|
||||
|
||||
Install the repo extra:
|
||||
|
||||
```bash
|
||||
uv sync --extra cloudflare
|
||||
```
|
||||
|
||||
Export the required environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export CLOUDFLARE_SANDBOX_WORKER_URL=...
|
||||
```
|
||||
|
||||
If your Cloudflare Sandbox Service worker requires bearer auth, also export:
|
||||
|
||||
```bash
|
||||
export CLOUDFLARE_SANDBOX_API_KEY=...
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/extensions/cloudflare_runner.py --stream
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--stream` -- stream model output to the terminal.
|
||||
- `--demo pty` -- run a PTY demo (interactive Python session with `tty=true`).
|
||||
- `--skip-snapshot-check` -- skip the stop/resume snapshot round-trip verification.
|
||||
- `--native-cloud-bucket-name <bucket>` -- mount an R2/S3 bucket via `CloudflareBucketMountStrategy`.
|
||||
- `--native-cloud-bucket-endpoint-url <url>` -- optional S3 endpoint URL.
|
||||
- `--api-key <key>` -- bearer token for the worker (or set `CLOUDFLARE_SANDBOX_API_KEY`).
|
||||
|
||||
|
||||
Cloudflare sandboxes support native cloud bucket mounts through
|
||||
`CloudflareBucketMountStrategy` on `S3Mount`, `R2Mount`, and HMAC-authenticated
|
||||
`GCSMount`.
|
||||
|
||||
## What to expect
|
||||
|
||||
Each script asks the model to inspect a small workspace and summarize it. A
|
||||
successful run should:
|
||||
|
||||
1. Start the chosen cloud sandbox backend.
|
||||
2. Materialize the manifest into the sandbox workspace.
|
||||
3. Call the shell tool at least once.
|
||||
4. Print either streamed text or a final short answer about the workspace.
|
||||
|
||||
These examples are not live-validated in CI because they depend on external
|
||||
cloud credentials, but they are shaped so contributors can verify backend
|
||||
behavior locally with one command per provider.
|
||||
|
||||
## Vercel
|
||||
|
||||
### Setup
|
||||
|
||||
Install the repo extra:
|
||||
|
||||
```bash
|
||||
uv sync --extra vercel
|
||||
```
|
||||
|
||||
Export the required environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export VERCEL_OIDC_TOKEN=...
|
||||
```
|
||||
|
||||
Or use explicit token and scope variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export VERCEL_TOKEN=...
|
||||
export VERCEL_PROJECT_ID=...
|
||||
export VERCEL_TEAM_ID=...
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/extensions/vercel_runner.py --stream
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--workspace-persistence tar`
|
||||
- `--workspace-persistence snapshot`
|
||||
- `--runtime node22`
|
||||
- `--timeout-ms 120000`
|
||||
|
||||
The Vercel example stays on the non-PTY path on purpose. It covers command
|
||||
execution, workspace materialization, and persistence verification without
|
||||
depending on interactive websocket support.
|
||||
|
||||
## Daytona
|
||||
|
||||
### Setup
|
||||
|
||||
Install the repo extra:
|
||||
|
||||
```bash
|
||||
uv sync --extra daytona
|
||||
```
|
||||
|
||||
Export the required environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export DAYTONA_API_KEY=...
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/extensions/daytona/daytona_runner.py --stream
|
||||
```
|
||||
|
||||
## Runloop
|
||||
|
||||
### Setup
|
||||
|
||||
Install the repo extra:
|
||||
|
||||
```bash
|
||||
uv sync --extra runloop
|
||||
```
|
||||
|
||||
Sign up for Runloop, no credit card required and $50 in credits @ [platform.runloop.ai](https://platform.runloop.ai/).
|
||||
Export the required environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export RUNLOOP_API_KEY=...
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/extensions/runloop/runner.py --stream
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--blueprint-name <name>`
|
||||
- `--pause-on-exit`
|
||||
- `--root`
|
||||
|
||||
Runloop-specific SDK features are also available directly on
|
||||
`RunloopSandboxClientOptions` and `RunloopSandboxClient.platform`. Example:
|
||||
|
||||
```python
|
||||
from agents.extensions.sandbox.runloop import (
|
||||
RunloopAfterIdle,
|
||||
RunloopGatewaySpec,
|
||||
RunloopLaunchParameters,
|
||||
RunloopMcpSpec,
|
||||
RunloopSandboxClient,
|
||||
RunloopSandboxClientOptions,
|
||||
RunloopTunnelConfig,
|
||||
)
|
||||
|
||||
client = RunloopSandboxClient()
|
||||
sandbox = await client.create(
|
||||
options=RunloopSandboxClientOptions(
|
||||
blueprint_name="python-3-12",
|
||||
launch_parameters=RunloopLaunchParameters(
|
||||
network_policy_id="np_123",
|
||||
resource_size_request="MEDIUM",
|
||||
after_idle=RunloopAfterIdle(idle_time_seconds=300, on_idle="suspend"),
|
||||
),
|
||||
tunnel=RunloopTunnelConfig(auth_mode="authenticated"),
|
||||
gateways={
|
||||
"OPENAI_GATEWAY": RunloopGatewaySpec(
|
||||
gateway="openai",
|
||||
secret="OPENAI_GATEWAY_SECRET",
|
||||
)
|
||||
},
|
||||
mcp={
|
||||
"GITHUB_MCP": RunloopMcpSpec(
|
||||
mcp_config="github-readonly",
|
||||
secret="GITHUB_MCP_SECRET",
|
||||
)
|
||||
},
|
||||
managed_secrets={"OPENAI_API_KEY": "..."},
|
||||
metadata={"team": "agents"},
|
||||
)
|
||||
)
|
||||
|
||||
public_blueprints = await client.platform.blueprints.list_public()
|
||||
public_benchmarks = await client.platform.benchmarks.list_public()
|
||||
```
|
||||
|
||||
`managed_secrets` are stored as Runloop account secrets and only secret references
|
||||
are persisted in session state. The platform facade also exposes Runloop-native
|
||||
helpers for blueprints, benchmarks, secrets, network policies, and axons.
|
||||
|
||||
If you enable `--root`, Runloop launches the devbox with
|
||||
`launch_parameters.user_parameters={"username":"root","uid":0}`. In that mode,
|
||||
the default home and working directory become `/root`, so the example also uses
|
||||
`/root` as its manifest workspace root. If you configure root launch in your
|
||||
own code, either rely on that root-mode default or explicitly choose a
|
||||
`manifest.root` under `/root`.
|
||||
## Blaxel
|
||||
|
||||
### Setup
|
||||
|
||||
Install the repo extra:
|
||||
|
||||
```bash
|
||||
uv sync --extra blaxel
|
||||
```
|
||||
|
||||
Create a Blaxel account and get an API key. The official docs are:
|
||||
|
||||
- <https://docs.blaxel.ai>
|
||||
- <https://app.blaxel.ai>
|
||||
|
||||
Export the required environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export BL_API_KEY=...
|
||||
export BL_WORKSPACE=...
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/extensions/blaxel_runner.py --stream
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--image blaxel/py-app`
|
||||
- `--region us-pdx-1`
|
||||
- `--memory 4096`
|
||||
- `--ttl 1h`
|
||||
- `--pause-on-exit`
|
||||
- `--skip-snapshot-check`
|
||||
|
||||
The runner also includes standalone demos for individual features. Pass
|
||||
`--demo <name>` to run one:
|
||||
|
||||
- `pty` -- agent-driven interactive Python session via PTY
|
||||
- `drive` -- [Blaxel Drive mount](https://docs.blaxel.ai/Agent-drive/Overview) (persistent storage, requires `--drive-name`)
|
||||
|
||||
Blaxel sandboxes support cloud bucket mounts (S3, R2, GCS) through
|
||||
`BlaxelCloudBucketMountStrategy` and persistent drive mounts through
|
||||
`BlaxelDriveMountStrategy`. See the
|
||||
[Blaxel Drive docs](https://docs.blaxel.ai/Agent-drive/Overview) for details.
|
||||
@@ -0,0 +1 @@
|
||||
"""Manual validation examples for cloud sandbox extensions."""
|
||||
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Blaxel-backed sandbox example for manual validation.
|
||||
|
||||
This example mirrors the other cloud extension runners. It supports:
|
||||
- Standard agent run (non-streaming and streaming).
|
||||
- PTY interactive session demo (agent-driven).
|
||||
- Blaxel Drive mount demo (persistent storage).
|
||||
|
||||
Prerequisites:
|
||||
uv sync --extra blaxel
|
||||
export OPENAI_API_KEY=...
|
||||
export BL_API_KEY=...
|
||||
export BL_WORKSPACE=...
|
||||
|
||||
Run:
|
||||
# Basic agent run
|
||||
uv run python examples/sandbox/extensions/blaxel_runner.py --stream
|
||||
|
||||
# With a specific image and region
|
||||
uv run python examples/sandbox/extensions/blaxel_runner.py \\
|
||||
--image blaxel/py-app --region us-pdx-1 --stream
|
||||
|
||||
# PTY terminal demo (agent-driven interactive Python session)
|
||||
uv run python examples/sandbox/extensions/blaxel_runner.py --demo pty
|
||||
|
||||
# Drive mount demo (requires an existing drive, defaults region to us-was-1)
|
||||
uv run python examples/sandbox/extensions/blaxel_runner.py \\
|
||||
--demo drive --drive-name my-drive
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner, set_tracing_disabled
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.capabilities import Shell
|
||||
from agents.sandbox.entries import File
|
||||
from agents.sandbox.manifest import Environment
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest, tool_call_name
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import (
|
||||
DEFAULT_BLAXEL_WORKSPACE_ROOT,
|
||||
BlaxelDriveMountStrategy,
|
||||
BlaxelSandboxClient,
|
||||
BlaxelSandboxClientOptions,
|
||||
)
|
||||
from agents.extensions.sandbox.blaxel import BlaxelDriveMount
|
||||
except Exception as exc:
|
||||
raise SystemExit(
|
||||
"Blaxel sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra blaxel"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_MODEL = "gpt-5.4"
|
||||
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
|
||||
DEFAULT_PTY_QUESTION = (
|
||||
"Start an interactive Python session with `tty=true`. In that same session, compute "
|
||||
"`5 + 5`, then add 5 more to the previous result. Briefly report the outputs and "
|
||||
"confirm that you stayed in one Python process."
|
||||
)
|
||||
|
||||
|
||||
def _build_manifest() -> Manifest:
|
||||
"""Build a small demo manifest for the default agent run."""
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Blaxel Demo Workspace\n\nThis workspace validates the Blaxel sandbox backend.\n"
|
||||
),
|
||||
"project/status.md": (
|
||||
"# Project Status\n\n"
|
||||
"- Backend: Blaxel cloud sandbox\n"
|
||||
"- Region: auto-selected\n"
|
||||
"- Features: exec, file I/O, PTY, drives, preview URLs\n"
|
||||
),
|
||||
"project/tasks.md": (
|
||||
"# Tasks\n\n"
|
||||
"1. Inspect the workspace files.\n"
|
||||
"2. List all features mentioned in status.md.\n"
|
||||
"3. Summarize in 2-3 sentences.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
return Manifest(
|
||||
root=DEFAULT_BLAXEL_WORKSPACE_ROOT,
|
||||
entries=manifest.entries,
|
||||
environment=Environment(
|
||||
value={"DEMO_ENV": "blaxel-agent-demo"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _require_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
return value
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
def _stream_event_banner(event_name: str, raw_item: object) -> str | None:
|
||||
_ = raw_item
|
||||
if event_name == "tool_called":
|
||||
return "[tool call]"
|
||||
if event_name == "tool_output":
|
||||
return "[tool output]"
|
||||
return None
|
||||
|
||||
|
||||
def _raw_item_call_id(raw_item: object) -> str | None:
|
||||
if isinstance(raw_item, dict):
|
||||
call_id = raw_item.get("call_id") or raw_item.get("id")
|
||||
else:
|
||||
call_id = getattr(raw_item, "call_id", None) or getattr(raw_item, "id", None)
|
||||
return call_id if isinstance(call_id, str) and call_id else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PTY demo (agent-driven)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _run_pty_demo(
|
||||
*,
|
||||
model: str,
|
||||
question: str,
|
||||
image: str | None,
|
||||
region: str | None,
|
||||
) -> None:
|
||||
"""Demonstrate PTY interaction: start an interactive Python process and continue it."""
|
||||
agent = SandboxAgent(
|
||||
name="Blaxel PTY Demo",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Complete the task by interacting with the sandbox through the shell capability. "
|
||||
"Keep the final answer concise. "
|
||||
"Preserve process state when the task depends on it. If you start an interactive "
|
||||
"program, continue using that same process instead of launching a second one."
|
||||
),
|
||||
default_manifest=Manifest(
|
||||
root=DEFAULT_BLAXEL_WORKSPACE_ROOT,
|
||||
entries=text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Blaxel PTY Agent Example\n\n"
|
||||
"This workspace is used by the Blaxel PTY demo.\n"
|
||||
),
|
||||
}
|
||||
).entries,
|
||||
),
|
||||
capabilities=[Shell()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
client = BlaxelSandboxClient()
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=client,
|
||||
options=BlaxelSandboxClientOptions(
|
||||
name=f"blaxel-demo-pty-{uuid.uuid4().hex[:8]}",
|
||||
image=image,
|
||||
region=region,
|
||||
),
|
||||
),
|
||||
workflow_name="Blaxel PTY sandbox example",
|
||||
)
|
||||
|
||||
try:
|
||||
result = Runner.run_streamed(agent, question, run_config=run_config)
|
||||
|
||||
saw_text_delta = False
|
||||
saw_any_text = False
|
||||
tool_names_by_call_id: dict[str, str] = {}
|
||||
|
||||
async for event in result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
saw_any_text = True
|
||||
continue
|
||||
|
||||
if event.type != "run_item_stream_event":
|
||||
continue
|
||||
|
||||
raw_item = event.item.raw_item
|
||||
banner = _stream_event_banner(event.name, raw_item)
|
||||
if banner is None:
|
||||
continue
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
saw_text_delta = False
|
||||
|
||||
if event.name == "tool_called":
|
||||
t_name = tool_call_name(raw_item)
|
||||
call_id = _raw_item_call_id(raw_item)
|
||||
if call_id is not None and t_name:
|
||||
tool_names_by_call_id[call_id] = t_name
|
||||
if t_name:
|
||||
banner = f"{banner} {t_name}"
|
||||
elif event.name == "tool_output":
|
||||
call_id = _raw_item_call_id(raw_item)
|
||||
output_tool_name = tool_names_by_call_id.get(call_id or "")
|
||||
if output_tool_name:
|
||||
banner = f"{banner} {output_tool_name}"
|
||||
|
||||
print(banner)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
if not saw_any_text:
|
||||
print(result.final_output)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drive demo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _run_drive_demo(
|
||||
*,
|
||||
model: str,
|
||||
question: str | None,
|
||||
image: str | None,
|
||||
region: str | None,
|
||||
drive_name: str | None,
|
||||
stream: bool,
|
||||
) -> None:
|
||||
"""Mount a Blaxel Drive and write a file to it."""
|
||||
if not drive_name:
|
||||
print("Usage: --demo drive --drive-name <name>")
|
||||
print()
|
||||
print("You need an existing Blaxel Drive. Create one at:")
|
||||
print(" https://app.blaxel.ai or via the Blaxel CLI.")
|
||||
return
|
||||
|
||||
# Blaxel drives must be in the same region as the sandbox.
|
||||
effective_region = region or os.environ.get("BL_REGION") or "us-was-1"
|
||||
mount_path = "/mnt/demo-drive"
|
||||
|
||||
manifest = Manifest(
|
||||
root=DEFAULT_BLAXEL_WORKSPACE_ROOT,
|
||||
entries={
|
||||
"README.md": File(
|
||||
content=(b"# Blaxel Drive Demo\n\nThe drive is mounted at /mnt/demo-drive.\n")
|
||||
),
|
||||
"drive": BlaxelDriveMount(
|
||||
drive_name=drive_name,
|
||||
drive_mount_path=mount_path,
|
||||
mount_strategy=BlaxelDriveMountStrategy(),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
marker = f"demo-{uuid.uuid4().hex[:8]}"
|
||||
agent = SandboxAgent(
|
||||
name="Blaxel Drive Demo",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Execute the exact shell commands the user gives you. "
|
||||
"Do not explore, do not run any other commands. "
|
||||
"Report the stdout and stderr of each command you ran. "
|
||||
"You must run the exact commands from the user message using the shell tool. "
|
||||
"Do not substitute, rewrite, or add any commands. Just execute and report output."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[Shell()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
client = BlaxelSandboxClient()
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=client,
|
||||
options=BlaxelSandboxClientOptions(
|
||||
name=f"blaxel-demo-drive-{uuid.uuid4().hex[:8]}",
|
||||
image=image,
|
||||
region=effective_region,
|
||||
),
|
||||
),
|
||||
workflow_name="Blaxel drive demo",
|
||||
)
|
||||
|
||||
effective_question = question or (
|
||||
f"Run: echo 'drive persistence ok ({marker})' > {mount_path}/{marker}.txt && "
|
||||
f"cat {mount_path}/{marker}.txt && ls {mount_path}"
|
||||
)
|
||||
|
||||
if not stream:
|
||||
result = await Runner.run(agent, effective_question, run_config=run_config)
|
||||
print(result.final_output)
|
||||
else:
|
||||
stream_result = Runner.run_streamed(agent, effective_question, run_config=run_config)
|
||||
saw_text_delta = False
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
if saw_text_delta:
|
||||
print()
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard agent run (streaming / non-streaming)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main(
|
||||
*,
|
||||
model: str,
|
||||
question: str | None,
|
||||
image: str | None,
|
||||
region: str | None,
|
||||
memory: int | None,
|
||||
ttl: str | None,
|
||||
pause_on_exit: bool,
|
||||
stream: bool,
|
||||
demo: str | None,
|
||||
drive_name: str | None,
|
||||
) -> None:
|
||||
_require_env("OPENAI_API_KEY")
|
||||
|
||||
# Handle dedicated demos.
|
||||
if demo == "pty":
|
||||
await _run_pty_demo(
|
||||
model=model,
|
||||
question=question or DEFAULT_PTY_QUESTION,
|
||||
image=image,
|
||||
region=region,
|
||||
)
|
||||
return
|
||||
|
||||
if demo == "drive":
|
||||
await _run_drive_demo(
|
||||
model=model,
|
||||
question=question,
|
||||
image=image,
|
||||
region=region,
|
||||
drive_name=drive_name,
|
||||
stream=stream,
|
||||
)
|
||||
return
|
||||
|
||||
manifest = _build_manifest()
|
||||
agent = SandboxAgent(
|
||||
name="Blaxel Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the files before answering "
|
||||
"and keep the response concise. "
|
||||
"Do not invent files or statuses that are not present in the workspace. Cite the "
|
||||
"file names you inspected. Also run `echo $DEMO_ENV` to confirm environment "
|
||||
"variables are set."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=BlaxelSandboxClient(),
|
||||
options=BlaxelSandboxClientOptions(
|
||||
name=f"blaxel-demo-agent-{uuid.uuid4().hex[:8]}",
|
||||
image=image,
|
||||
region=region,
|
||||
memory=memory,
|
||||
ttl=ttl,
|
||||
labels={"purpose": "agent-demo", "source": "blaxel-runner"},
|
||||
pause_on_exit=pause_on_exit,
|
||||
),
|
||||
),
|
||||
workflow_name="Blaxel sandbox example",
|
||||
)
|
||||
|
||||
effective_question = question or DEFAULT_QUESTION
|
||||
|
||||
if not stream:
|
||||
result = await Runner.run(agent, effective_question, run_config=run_config)
|
||||
print(result.final_output)
|
||||
return
|
||||
|
||||
stream_result = Runner.run_streamed(agent, effective_question, run_config=run_config)
|
||||
saw_text_delta = False
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
set_tracing_disabled(True)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Blaxel sandbox demo -- showcases sandbox features.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"demos:\n"
|
||||
" agent Run a sandboxed agent (default)\n"
|
||||
" pty Agent-driven PTY interactive terminal\n"
|
||||
" drive Mount a Blaxel Drive (requires --drive-name)\n"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--demo",
|
||||
choices=["agent", "pty", "drive"],
|
||||
default="agent",
|
||||
help="Which demo to run (default: agent).",
|
||||
)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name.")
|
||||
parser.add_argument("--question", default=None, help="Override the default prompt.")
|
||||
parser.add_argument("--stream", action="store_true", help="Stream response.")
|
||||
parser.add_argument("--image", default=None, help="Sandbox image.")
|
||||
parser.add_argument("--region", default=None, help="Sandbox region.")
|
||||
parser.add_argument("--memory", type=int, default=None, help="Memory in MB.")
|
||||
parser.add_argument("--ttl", default=None, help="Sandbox TTL (e.g. '1h').")
|
||||
parser.add_argument("--pause-on-exit", action="store_true", help="Pause on exit.")
|
||||
parser.add_argument("--drive-name", default=None, help="Drive name for drive demo.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
model=args.model,
|
||||
question=args.question,
|
||||
image=args.image,
|
||||
region=args.region,
|
||||
memory=args.memory,
|
||||
ttl=args.ttl,
|
||||
pause_on_exit=args.pause_on_exit,
|
||||
stream=args.stream,
|
||||
demo=args.demo,
|
||||
drive_name=args.drive_name,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
Cloudflare-backed sandbox example for manual validation.
|
||||
|
||||
This example mirrors the Modal and E2B extension runners. It supports:
|
||||
- Standard agent run (non-streaming and streaming).
|
||||
- Snapshot stop/resume round-trip verification.
|
||||
- PTY interactive session demo.
|
||||
- Cloud bucket mount demo (R2/S3/GCS via CloudflareBucketMountStrategy).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner, set_tracing_disabled
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.capabilities import Shell
|
||||
from agents.sandbox.entries import File, R2Mount, S3Mount
|
||||
from agents.sandbox.session import BaseSandboxSession
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest, tool_call_name
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import (
|
||||
CloudflareBucketMountStrategy,
|
||||
CloudflareSandboxClient,
|
||||
CloudflareSandboxClientOptions,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
||||
raise SystemExit(
|
||||
"Cloudflare sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra cloudflare"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_MODEL = "gpt-5.4"
|
||||
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
|
||||
DEFAULT_PTY_QUESTION = (
|
||||
"Start an interactive Python session with `tty=true`. In that same session, compute "
|
||||
"`5 + 5`, then add 5 more to the previous result. Briefly report the outputs and "
|
||||
"confirm that you stayed in one Python process."
|
||||
)
|
||||
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
|
||||
SNAPSHOT_CHECK_CONTENT = "cloudflare snapshot round-trip ok\n"
|
||||
|
||||
|
||||
def _build_manifest(
|
||||
*,
|
||||
native_cloud_bucket_name: str | None = None,
|
||||
native_cloud_bucket_mount_path: str | None = None,
|
||||
native_cloud_bucket_endpoint_url: str | None = None,
|
||||
) -> Manifest:
|
||||
"""Build a small demo manifest, optionally including a cloud bucket mount."""
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Cloudflare Demo Workspace\n\n"
|
||||
"This workspace exists to validate the Cloudflare sandbox backend manually.\n"
|
||||
),
|
||||
"incident.md": (
|
||||
"# Incident\n\n"
|
||||
"- Customer: Fabrikam Retail.\n"
|
||||
"- Issue: delayed reporting rollout.\n"
|
||||
"- Primary blocker: incomplete security questionnaire.\n"
|
||||
),
|
||||
"plan.md": (
|
||||
"# Plan\n\n"
|
||||
"1. Close the questionnaire.\n"
|
||||
"2. Reconfirm the rollout date with the customer.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
if native_cloud_bucket_name is None:
|
||||
return manifest
|
||||
|
||||
# Determine whether this looks like an R2 bucket (has account ID) or S3.
|
||||
account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID")
|
||||
if account_id:
|
||||
manifest.entries["cloud-bucket"] = R2Mount(
|
||||
bucket=native_cloud_bucket_name,
|
||||
account_id=account_id,
|
||||
access_key_id=os.environ.get("R2_ACCESS_KEY_ID"),
|
||||
secret_access_key=os.environ.get("R2_SECRET_ACCESS_KEY"),
|
||||
mount_path=Path(native_cloud_bucket_mount_path)
|
||||
if native_cloud_bucket_mount_path is not None
|
||||
else None,
|
||||
read_only=False,
|
||||
mount_strategy=CloudflareBucketMountStrategy(),
|
||||
)
|
||||
else:
|
||||
manifest.entries["cloud-bucket"] = S3Mount(
|
||||
bucket=native_cloud_bucket_name,
|
||||
access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
|
||||
secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
||||
endpoint_url=native_cloud_bucket_endpoint_url,
|
||||
mount_path=Path(native_cloud_bucket_mount_path)
|
||||
if native_cloud_bucket_mount_path is not None
|
||||
else None,
|
||||
read_only=False,
|
||||
mount_strategy=CloudflareBucketMountStrategy(),
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def _build_pty_manifest() -> Manifest:
|
||||
"""Build a tiny manifest for the PTY demo."""
|
||||
return Manifest(
|
||||
entries={
|
||||
"README.md": File(
|
||||
content=(
|
||||
b"# Cloudflare PTY Agent Example\n\n"
|
||||
b"This workspace is used by the Cloudflare PTY demo.\n"
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
return value
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
async def _read_text(session: BaseSandboxSession, path: Path) -> str:
|
||||
data = await session.read(path)
|
||||
text = cast(str | bytes, data.read())
|
||||
if isinstance(text, bytes):
|
||||
return text.decode("utf-8")
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stop/resume snapshot round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _verify_stop_resume(*, worker_url: str, api_key: str | None) -> None:
|
||||
"""Create a sandbox, write a file, stop, resume, and verify the file persisted."""
|
||||
client = CloudflareSandboxClient()
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"README.md": "# Snapshot test\n",
|
||||
}
|
||||
)
|
||||
options = CloudflareSandboxClientOptions(worker_url=worker_url, api_key=api_key)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cf-snapshot-example-") as snapshot_dir:
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
|
||||
options=options,
|
||||
)
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
await sandbox.write(
|
||||
SNAPSHOT_CHECK_PATH,
|
||||
io.BytesIO(SNAPSHOT_CHECK_CONTENT.encode("utf-8")),
|
||||
)
|
||||
await sandbox.stop()
|
||||
finally:
|
||||
await sandbox.shutdown()
|
||||
|
||||
resumed_sandbox = await client.resume(sandbox.state)
|
||||
try:
|
||||
await resumed_sandbox.start()
|
||||
restored_text = await _read_text(resumed_sandbox, SNAPSHOT_CHECK_PATH)
|
||||
if restored_text != SNAPSHOT_CHECK_CONTENT:
|
||||
raise RuntimeError(
|
||||
f"Snapshot resume verification failed: "
|
||||
f"expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}"
|
||||
)
|
||||
finally:
|
||||
await resumed_sandbox.aclose()
|
||||
|
||||
print("snapshot round-trip ok")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PTY demo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stream_event_banner(event_name: str, raw_item: object) -> str | None:
|
||||
_ = raw_item
|
||||
if event_name == "tool_called":
|
||||
return "[tool call]"
|
||||
if event_name == "tool_output":
|
||||
return "[tool output]"
|
||||
return None
|
||||
|
||||
|
||||
def _raw_item_call_id(raw_item: object) -> str | None:
|
||||
if isinstance(raw_item, dict):
|
||||
call_id = raw_item.get("call_id") or raw_item.get("id")
|
||||
else:
|
||||
call_id = getattr(raw_item, "call_id", None) or getattr(raw_item, "id", None)
|
||||
return call_id if isinstance(call_id, str) and call_id else None
|
||||
|
||||
|
||||
async def _run_pty_demo(*, model: str, worker_url: str, api_key: str | None) -> None:
|
||||
"""Demonstrate PTY interaction: start an interactive Python process and continue it."""
|
||||
agent = SandboxAgent(
|
||||
name="Cloudflare PTY Demo",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Complete the task by interacting with the sandbox through the shell capability. "
|
||||
"Keep the final answer concise. "
|
||||
"Preserve process state when the task depends on it. If you start an interactive "
|
||||
"program, continue using that same process instead of launching a second one."
|
||||
),
|
||||
default_manifest=_build_pty_manifest(),
|
||||
capabilities=[Shell()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
client = CloudflareSandboxClient()
|
||||
sandbox = await client.create(
|
||||
manifest=agent.default_manifest,
|
||||
options=CloudflareSandboxClientOptions(worker_url=worker_url, api_key=api_key),
|
||||
)
|
||||
|
||||
try:
|
||||
async with sandbox:
|
||||
result = Runner.run_streamed(
|
||||
agent,
|
||||
DEFAULT_PTY_QUESTION,
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name="Cloudflare PTY sandbox example",
|
||||
),
|
||||
)
|
||||
|
||||
saw_text_delta = False
|
||||
saw_any_text = False
|
||||
tool_names_by_call_id: dict[str, str] = {}
|
||||
|
||||
async for event in result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
saw_any_text = True
|
||||
continue
|
||||
|
||||
if event.type != "run_item_stream_event":
|
||||
continue
|
||||
|
||||
raw_item = event.item.raw_item
|
||||
banner = _stream_event_banner(event.name, raw_item)
|
||||
if banner is None:
|
||||
continue
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
saw_text_delta = False
|
||||
|
||||
if event.name == "tool_called":
|
||||
t_name = tool_call_name(raw_item)
|
||||
call_id = _raw_item_call_id(raw_item)
|
||||
if call_id is not None and t_name:
|
||||
tool_names_by_call_id[call_id] = t_name
|
||||
if t_name:
|
||||
banner = f"{banner} {t_name}"
|
||||
elif event.name == "tool_output":
|
||||
call_id = _raw_item_call_id(raw_item)
|
||||
output_tool_name = tool_names_by_call_id.get(call_id or "")
|
||||
if output_tool_name:
|
||||
banner = f"{banner} {output_tool_name}"
|
||||
|
||||
print(banner)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
if not saw_any_text:
|
||||
print(result.final_output)
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard agent run (streaming / non-streaming)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main(
|
||||
*,
|
||||
model: str,
|
||||
question: str,
|
||||
worker_url: str,
|
||||
api_key: str | None,
|
||||
stream: bool,
|
||||
demo: str | None,
|
||||
skip_snapshot_check: bool,
|
||||
native_cloud_bucket_name: str | None,
|
||||
native_cloud_bucket_mount_path: str,
|
||||
native_cloud_bucket_endpoint_url: str | None,
|
||||
) -> None:
|
||||
_require_env("OPENAI_API_KEY")
|
||||
|
||||
# Handle dedicated demos.
|
||||
if demo == "pty":
|
||||
await _run_pty_demo(model=model, worker_url=worker_url, api_key=api_key)
|
||||
return
|
||||
|
||||
# Snapshot stop/resume round-trip.
|
||||
if not skip_snapshot_check:
|
||||
await _verify_stop_resume(worker_url=worker_url, api_key=api_key)
|
||||
|
||||
manifest = _build_manifest(
|
||||
native_cloud_bucket_name=native_cloud_bucket_name,
|
||||
native_cloud_bucket_mount_path=native_cloud_bucket_mount_path,
|
||||
native_cloud_bucket_endpoint_url=native_cloud_bucket_endpoint_url,
|
||||
)
|
||||
agent = SandboxAgent(
|
||||
name="Cloudflare Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the files before answering "
|
||||
"and keep the response concise. "
|
||||
"Do not invent files or statuses that are not present in the workspace. Cite the "
|
||||
"file names you inspected."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[Shell()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=CloudflareSandboxClient(),
|
||||
options=CloudflareSandboxClientOptions(worker_url=worker_url, api_key=api_key),
|
||||
),
|
||||
workflow_name="Cloudflare sandbox example",
|
||||
)
|
||||
|
||||
if not stream:
|
||||
result = await Runner.run(agent, question, run_config=run_config)
|
||||
print(result.final_output)
|
||||
return
|
||||
|
||||
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
|
||||
saw_text_delta = False
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
set_tracing_disabled(True)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run a Cloudflare sandbox agent with optional PTY, streaming, and snapshot demos."
|
||||
)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
|
||||
parser.add_argument(
|
||||
"--question",
|
||||
default=DEFAULT_QUESTION,
|
||||
help="Prompt to send to the agent.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worker-url",
|
||||
default=os.environ.get("CLOUDFLARE_SANDBOX_WORKER_URL"),
|
||||
help="Cloudflare Worker base URL. Defaults to CLOUDFLARE_SANDBOX_WORKER_URL.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
default=os.environ.get("CLOUDFLARE_SANDBOX_API_KEY"),
|
||||
help="Optional bearer token for the worker. Defaults to CLOUDFLARE_SANDBOX_API_KEY.",
|
||||
)
|
||||
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
|
||||
parser.add_argument(
|
||||
"--demo",
|
||||
default=None,
|
||||
choices=["pty"],
|
||||
help="Run a standalone demo instead of the standard agent flow.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-snapshot-check",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip the snapshot stop/resume round-trip verification.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-name",
|
||||
default=None,
|
||||
help="Optional R2/S3 bucket name to mount with CloudflareBucketMountStrategy.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-mount-path",
|
||||
default="cloud-bucket",
|
||||
help=(
|
||||
"Mount path for --native-cloud-bucket-name. Relative paths are resolved under the "
|
||||
"workspace root."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-endpoint-url",
|
||||
default=None,
|
||||
help="Optional endpoint URL for --native-cloud-bucket-name (S3 only).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.worker_url:
|
||||
raise SystemExit(
|
||||
"A Cloudflare Worker URL is required. Pass --worker-url or set CLOUDFLARE_SANDBOX_WORKER_URL."
|
||||
)
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
model=args.model,
|
||||
question=args.question,
|
||||
worker_url=args.worker_url,
|
||||
api_key=args.api_key,
|
||||
stream=args.stream,
|
||||
demo=args.demo,
|
||||
skip_snapshot_check=args.skip_snapshot_check,
|
||||
native_cloud_bucket_name=args.native_cloud_bucket_name,
|
||||
native_cloud_bucket_mount_path=args.native_cloud_bucket_mount_path,
|
||||
native_cloud_bucket_endpoint_url=args.native_cloud_bucket_endpoint_url,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Daytona sandbox extension examples."""
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Minimal Daytona-backed sandbox example for manual validation.
|
||||
|
||||
This mirrors the E2B and Modal extension examples: it creates a tiny workspace,
|
||||
asks a sandboxed agent to inspect it through one shell tool, and prints a short
|
||||
answer.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.entries import S3Mount
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import (
|
||||
DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
||||
DaytonaCloudBucketMountStrategy,
|
||||
DaytonaSandboxClient,
|
||||
DaytonaSandboxClientOptions,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
||||
raise SystemExit(
|
||||
"Daytona sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra daytona"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
|
||||
|
||||
|
||||
def _build_manifest(
|
||||
*,
|
||||
cloud_bucket_name: str | None = None,
|
||||
cloud_bucket_mount_path: str | None = None,
|
||||
cloud_bucket_endpoint_url: str | None = None,
|
||||
cloud_bucket_key_prefix: str | None = None,
|
||||
) -> Manifest:
|
||||
"""Build a small demo manifest, optionally including a cloud bucket mount."""
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Daytona Demo Workspace\n\n"
|
||||
"This workspace exists to validate the Daytona sandbox backend manually.\n"
|
||||
),
|
||||
"launch.md": (
|
||||
"# Launch\n\n"
|
||||
"- Customer: Contoso Logistics.\n"
|
||||
"- Goal: validate the remote sandbox agent path.\n"
|
||||
"- Current status: Daytona backend smoke and app-server connectivity are passing.\n"
|
||||
),
|
||||
"tasks.md": (
|
||||
"# Tasks\n\n"
|
||||
"1. Inspect the workspace files.\n"
|
||||
"2. Summarize the setup and any notable status in two sentences.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
if cloud_bucket_name is None:
|
||||
return Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT, entries=manifest.entries)
|
||||
|
||||
manifest.entries["cloud-bucket"] = S3Mount(
|
||||
bucket=cloud_bucket_name,
|
||||
access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
|
||||
secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
||||
session_token=os.environ.get("AWS_SESSION_TOKEN"),
|
||||
endpoint_url=cloud_bucket_endpoint_url,
|
||||
prefix=cloud_bucket_key_prefix,
|
||||
mount_path=Path(cloud_bucket_mount_path) if cloud_bucket_mount_path is not None else None,
|
||||
read_only=False,
|
||||
mount_strategy=DaytonaCloudBucketMountStrategy(),
|
||||
)
|
||||
return Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT, entries=manifest.entries)
|
||||
|
||||
|
||||
def _require_env(name: str) -> None:
|
||||
if os.environ.get(name):
|
||||
return
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
async def main(
|
||||
*,
|
||||
model: str,
|
||||
question: str,
|
||||
pause_on_exit: bool,
|
||||
stream: bool,
|
||||
cloud_bucket_name: str | None = None,
|
||||
cloud_bucket_mount_path: str | None = None,
|
||||
cloud_bucket_endpoint_url: str | None = None,
|
||||
cloud_bucket_key_prefix: str | None = None,
|
||||
) -> None:
|
||||
_require_env("OPENAI_API_KEY")
|
||||
_require_env("DAYTONA_API_KEY")
|
||||
|
||||
manifest = _build_manifest(
|
||||
cloud_bucket_name=cloud_bucket_name,
|
||||
cloud_bucket_mount_path=cloud_bucket_mount_path,
|
||||
cloud_bucket_endpoint_url=cloud_bucket_endpoint_url,
|
||||
cloud_bucket_key_prefix=cloud_bucket_key_prefix,
|
||||
)
|
||||
agent = SandboxAgent(
|
||||
name="Daytona Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the files before answering "
|
||||
"and keep the response concise. "
|
||||
"Do not invent files or statuses that are not present in the workspace. Cite the "
|
||||
"file names you inspected."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
client = DaytonaSandboxClient()
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=client,
|
||||
options=DaytonaSandboxClientOptions(pause_on_exit=pause_on_exit),
|
||||
),
|
||||
workflow_name="Daytona sandbox example",
|
||||
)
|
||||
|
||||
try:
|
||||
if not stream:
|
||||
result = await Runner.run(agent, question, run_config=run_config)
|
||||
print(result.final_output)
|
||||
return
|
||||
|
||||
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
|
||||
saw_text_delta = False
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
parser.add_argument(
|
||||
"--pause-on-exit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Pause the Daytona sandbox on shutdown instead of deleting it.",
|
||||
)
|
||||
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
|
||||
parser.add_argument(
|
||||
"--cloud-bucket-name",
|
||||
default=None,
|
||||
help="S3 bucket name to mount into the sandbox.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cloud-bucket-mount-path",
|
||||
default=None,
|
||||
help=(
|
||||
"Mount path for --cloud-bucket-name. Relative paths are resolved under the "
|
||||
"workspace root. Defaults to the mount class default."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cloud-bucket-endpoint-url",
|
||||
default=None,
|
||||
help="Optional endpoint URL for --cloud-bucket-name (S3 only, e.g. MinIO).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cloud-bucket-key-prefix",
|
||||
default=None,
|
||||
help="Optional key prefix for --cloud-bucket-name.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
model=args.model,
|
||||
question=args.question,
|
||||
pause_on_exit=args.pause_on_exit,
|
||||
stream=args.stream,
|
||||
cloud_bucket_name=args.cloud_bucket_name,
|
||||
cloud_bucket_mount_path=args.cloud_bucket_mount_path,
|
||||
cloud_bucket_endpoint_url=args.cloud_bucket_endpoint_url,
|
||||
cloud_bucket_key_prefix=args.cloud_bucket_key_prefix,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
# NASA Spending Text-to-SQL Agent
|
||||
|
||||
Multi-turn conversational agent that translates natural-language questions about NASA federal
|
||||
spending into SQL queries, executes them against a local SQLite database, and returns structured
|
||||
tabular results.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Schema knowledge**: The agent receives a compact schema summary in its system prompt and can
|
||||
read detailed per-table documentation from workspace files on demand.
|
||||
2. **SQL execution**: A custom `SqlCapability` provides a `run_sql` tool with guardrails — read-only
|
||||
mode, statement validation, row limits, and query timeouts. The agent is instructed to use
|
||||
`run_sql` for all queries; the tool enforces read-only access at the SQLite level.
|
||||
3. **Multi-turn conversation**: The agent retains context across turns, so you can ask follow-up
|
||||
questions like "break that down by year" or "just the top 5".
|
||||
4. **Compaction**: Uses the `Compaction` capability to automatically summarize older conversation
|
||||
context, keeping long sessions within the model's context window.
|
||||
5. **Pause/resume**: Type `exit` to pause the sandbox and quit. Run the script again to reconnect
|
||||
to the same paused sandbox — no re-download needed. If the sandbox can't be reconnected (e.g.
|
||||
it was deleted or expired), a fresh one is created and the database is rebuilt automatically.
|
||||
6. **Memory**: Uses the `Memory` capability to extract learnings from each conversation and
|
||||
consolidate them into structured files. On subsequent sessions, the agent starts with context
|
||||
from previous conversations (useful query patterns, data caveats, etc.).
|
||||
|
||||
## Data
|
||||
|
||||
The database contains NASA federal spending data from [USAspending.gov](https://usaspending.gov),
|
||||
defaulting to FY2021-FY2025 (configurable via `--start-fy`/`--end-fy` flags on `setup_db.py`).
|
||||
|
||||
It uses a single `spending` table where each row is one transaction (obligation, modification,
|
||||
or de-obligation) on a federal award. The agent aggregates as needed via SQL.
|
||||
|
||||
The database is built automatically on first run (requires internet access in the sandbox).
|
||||
Subsequent runs reuse the existing database.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- `openai-agents` installed with Daytona support (`uv sync --extra daytona` from repo root)
|
||||
- `OPENAI_API_KEY` environment variable set (for the LLM)
|
||||
- `DAYTONA_API_KEY` environment variable set (for the sandbox — get one at [daytona.io](https://daytona.io))
|
||||
- Internet access (for first-run database setup inside the sandbox)
|
||||
|
||||
## Run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export DAYTONA_API_KEY="..."
|
||||
uv run python -m examples.sandbox.extensions.daytona.usaspending_text2sql.agent
|
||||
```
|
||||
|
||||
## Example questions
|
||||
|
||||
```
|
||||
> What are NASA's top 10 contractors by total spending?
|
||||
> Break that down by fiscal year
|
||||
> Which NASA centers award the most contracts?
|
||||
> Show me grants to universities in California
|
||||
> How has NASA spending changed over time?
|
||||
> What are the largest individual awards in the last 3 years?
|
||||
> Compare contract vs grant spending by year
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
daytona/usaspending_text2sql/
|
||||
├── agent.py — SandboxAgent definition + interactive REPL
|
||||
├── sql_capability.py — SqlCapability (Capability) with run_sql tool and guardrails
|
||||
├── setup_db.py — Runs inside sandbox; fetches data from USAspending API, builds SQLite DB
|
||||
├── schema/
|
||||
│ ├── overview.md — Compact schema summary (injected into instructions)
|
||||
│ └── tables/ — Per-table column documentation (read on demand via Shell capability)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### SQL guardrails (defense in depth)
|
||||
|
||||
1. **Connection-level**: SQLite opened with `?mode=ro` URI (read-only)
|
||||
2. **PRAGMA**: `query_only = ON` prevents writes even if validation is bypassed
|
||||
3. **Statement validation**: Only `SELECT`, `WITH`, `EXPLAIN`, `PRAGMA` are allowed
|
||||
4. **Row limit**: Hard cap (default 100 rows) with truncation detection
|
||||
5. **Timeout**: Queries killed after 30 seconds
|
||||
|
||||
### Audit log
|
||||
|
||||
All sandbox operations (exec calls, start/stop, SQL queries and their results) are logged to
|
||||
`.audit_log.jsonl` as structured JSONL events via the SDK's `Instrumentation` and `JsonlOutboxSink`.
|
||||
This is useful for debugging, replaying sessions, or inspecting exactly what SQL the agent ran.
|
||||
|
||||
### Sandbox
|
||||
|
||||
This example uses Daytona as its sandbox backend. The agent and capability definitions are
|
||||
backend-agnostic, but the entrypoint (`agent.py`) hardcodes `DaytonaSandboxClient` and
|
||||
Daytona-specific features like pause/resume.
|
||||
@@ -0,0 +1 @@
|
||||
"""USAspending text-to-SQL Daytona sandbox example."""
|
||||
@@ -0,0 +1,504 @@
|
||||
"""NASA spending text-to-SQL agent.
|
||||
|
||||
Multi-turn conversational agent that translates natural-language questions
|
||||
about NASA federal spending into SQL queries, executes them against a
|
||||
USAspending SQLite database, and returns structured results.
|
||||
|
||||
Usage:
|
||||
uv run python -m examples.sandbox.extensions.daytona.usaspending_text2sql.agent
|
||||
|
||||
The database is built automatically inside the sandbox on first run by
|
||||
executing setup_db.py (requires internet access). Subsequent runs reuse the
|
||||
existing database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.capabilities.compaction import Compaction
|
||||
from agents.sandbox.capabilities.memory import Memory
|
||||
from agents.sandbox.capabilities.shell import Shell
|
||||
from agents.sandbox.config import MemoryGenerateConfig, MemoryReadConfig
|
||||
from agents.sandbox.entries import Dir, File, LocalDir, LocalFile
|
||||
from agents.sandbox.session import (
|
||||
EventPayloadPolicy,
|
||||
Instrumentation,
|
||||
JsonlOutboxSink,
|
||||
)
|
||||
from examples.sandbox.extensions.daytona.usaspending_text2sql.sql_capability import (
|
||||
SqlCapability,
|
||||
)
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import (
|
||||
DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
||||
DaytonaSandboxClient,
|
||||
DaytonaSandboxClientOptions,
|
||||
DaytonaSandboxSessionState,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Daytona sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra daytona"
|
||||
) from exc
|
||||
|
||||
EXAMPLE_DIR = Path(__file__).parent
|
||||
SCHEMA_DIR = EXAMPLE_DIR / "schema"
|
||||
SETUP_DB_PATH = EXAMPLE_DIR / "setup_db.py"
|
||||
SESSION_STATE_PATH = EXAMPLE_DIR / ".session_state.json"
|
||||
AUDIT_LOG_PATH = EXAMPLE_DIR / ".audit_log.jsonl"
|
||||
|
||||
# Set at runtime once the exposed port is resolved.
|
||||
_downloads_base_url: str = ""
|
||||
|
||||
DEVELOPER_INSTRUCTIONS = (
|
||||
(SCHEMA_DIR / "overview.md").read_text()
|
||||
+ """
|
||||
|
||||
## Instructions
|
||||
|
||||
- Always use the `run_sql` tool to query the database. Never attempt to run sqlite3 directly.
|
||||
- Read schema documentation from schema/tables/ if you need detailed column information.
|
||||
- Read schema/glossary.md for official USAspending term definitions (e.g., what "obligation" vs "outlay" means).
|
||||
- Prefer aggregations (GROUP BY, SUM, COUNT, AVG) over returning many raw rows.
|
||||
- Format monetary values with dollar signs and commas in your final answers (e.g., $1,234,567).
|
||||
- When the user asks a follow-up question, use conversation context to understand references
|
||||
like "break that down by year" or "just the top 5".
|
||||
- If a query fails, read the error message and try to fix the SQL.
|
||||
- Explain your query logic briefly so the user can verify correctness.
|
||||
|
||||
## Data caveats
|
||||
|
||||
- The database contains **obligations** (money legally committed), not outlays (money actually paid).
|
||||
When the user asks about "spending", clarify that these are obligation amounts.
|
||||
- Amounts are tied to the **action_date** (when the obligation was signed), not when the work happens.
|
||||
A multi-year contract may appear entirely in the fiscal year it was obligated.
|
||||
- Some recipients are masked as "MULTIPLE RECIPIENTS" or "REDACTED DUE TO PII" for privacy reasons.
|
||||
Mention this if recipient-level analysis looks incomplete.
|
||||
"""
|
||||
)
|
||||
|
||||
DB_PATH = "data/usaspending.db"
|
||||
|
||||
WORKSPACE_ROOT = DEFAULT_DAYTONA_WORKSPACE_ROOT
|
||||
|
||||
|
||||
def build_agent() -> SandboxAgent:
|
||||
"""Build the agent blueprint."""
|
||||
manifest = Manifest(
|
||||
root=WORKSPACE_ROOT,
|
||||
entries={
|
||||
"setup_db.py": LocalFile(src=SETUP_DB_PATH),
|
||||
"schema": LocalDir(src=SCHEMA_DIR),
|
||||
"data": Dir(ephemeral=True),
|
||||
"memory/memory_summary.md": File(content=b""),
|
||||
"memory/phase_two_selection.json": File(content=b""),
|
||||
},
|
||||
)
|
||||
|
||||
return SandboxAgent(
|
||||
name="NASA Spending Q&A",
|
||||
default_manifest=manifest,
|
||||
model="gpt-5.4",
|
||||
instructions=(
|
||||
"You are a helpful data analyst that answers questions about NASA federal spending "
|
||||
"by writing and executing SQL queries.\n\n" + DEVELOPER_INSTRUCTIONS
|
||||
),
|
||||
capabilities=[
|
||||
SqlCapability(db_path=DB_PATH),
|
||||
Shell(),
|
||||
Compaction(),
|
||||
Memory(
|
||||
read=MemoryReadConfig(live_update=False),
|
||||
generate=MemoryGenerateConfig(
|
||||
extra_prompt=(
|
||||
"Pay attention to which SQL patterns work best for the USAspending data, "
|
||||
"column quirks (e.g. recipient_parent_name vs recipient_name for grouping), "
|
||||
"and data caveats the user discovers (e.g. negative obligations, masked "
|
||||
"recipients)."
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Terminal formatting helpers (unchanged from universal_computer version)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DIM = "\033[2;39m"
|
||||
DIM_CYAN = "\033[2;36m"
|
||||
DIM_BLUE = "\033[2;34m"
|
||||
DIM_YELLOW = "\033[2;33m"
|
||||
DIM_GREEN = "\033[2;32m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
_SQL_KEYWORDS = (
|
||||
r"\b(?:SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|CROSS|FULL|NATURAL|ON|AND|OR"
|
||||
r"|NOT|IN|IS|NULL|AS|WITH|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|UNION"
|
||||
r"|ALL|DISTINCT|CASE|WHEN|THEN|ELSE|END|EXISTS|BETWEEN|LIKE|INSERT|UPDATE"
|
||||
r"|DELETE|CREATE|DROP|ALTER|SET|VALUES|INTO|TABLE|INDEX|VIEW|ASC|DESC|BY"
|
||||
r"|OVER|PARTITION\s+BY)\b"
|
||||
)
|
||||
|
||||
_SQL_FUNCTIONS = (
|
||||
r"\b(?:COUNT|SUM|AVG|MIN|MAX|COALESCE|CAST|SUBSTR|LENGTH|ROUND|ABS|IFNULL"
|
||||
r"|NULLIF|REPLACE|TRIM|UPPER|LOWER|DATE|DATETIME|STRFTIME|TYPEOF|TOTAL"
|
||||
r"|GROUP_CONCAT|PRINTF|ROW_NUMBER|RANK|DENSE_RANK)(?=\s*\()"
|
||||
)
|
||||
|
||||
_SQL_STRING = r"'(?:''|[^'])*'"
|
||||
|
||||
|
||||
def _highlight_sql(sql: str) -> str:
|
||||
"""Apply ANSI syntax highlighting to a SQL string."""
|
||||
placeholders: list[str] = []
|
||||
|
||||
def _stash_string(m: re.Match[str]) -> str:
|
||||
placeholders.append(m.group(0))
|
||||
return f"\x00STR{len(placeholders) - 1}\x00"
|
||||
|
||||
result = re.sub(_SQL_STRING, _stash_string, sql)
|
||||
|
||||
result = re.sub(
|
||||
_SQL_KEYWORDS,
|
||||
lambda m: f"{DIM_BLUE}{m.group(0)}{DIM}",
|
||||
result,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
result = re.sub(
|
||||
_SQL_FUNCTIONS,
|
||||
lambda m: f"{DIM_YELLOW}{m.group(0)}{DIM}",
|
||||
result,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _restore_string(m: re.Match[str]) -> str:
|
||||
idx = int(m.group(1))
|
||||
return f"{DIM_GREEN}{placeholders[idx]}{DIM}"
|
||||
|
||||
result = re.sub(r"\x00STR(\d+)\x00", _restore_string, result)
|
||||
return result
|
||||
|
||||
|
||||
def _format_tool_args(name: str, arguments: str) -> str:
|
||||
"""Format a tool call for display, pretty-printing SQL queries."""
|
||||
if name == "run_sql":
|
||||
try:
|
||||
args = json.loads(arguments)
|
||||
query = args.get("query", "")
|
||||
limit = args.get("limit")
|
||||
header = f" {DIM}[SQL]"
|
||||
if limit is not None:
|
||||
header += f" (limit {limit})"
|
||||
header += RESET
|
||||
highlighted = _highlight_sql(query)
|
||||
sql = textwrap.indent(highlighted, " ")
|
||||
return f"{header}\n{DIM}{sql}{RESET}"
|
||||
except Exception:
|
||||
pass
|
||||
return f" {DIM}[tool] {name}({arguments}){RESET}"
|
||||
|
||||
|
||||
def _format_tool_result(output: str) -> str | None:
|
||||
"""Format a tool result for display. Returns None for non-SQL results."""
|
||||
try:
|
||||
data = json.loads(output)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
if output.strip():
|
||||
return f" {DIM}{output.strip()}{RESET}"
|
||||
return None
|
||||
|
||||
columns = data.get("columns")
|
||||
rows = data.get("rows")
|
||||
if not isinstance(columns, list) or not isinstance(rows, list):
|
||||
return None
|
||||
|
||||
row_count = data.get("row_count", len(rows))
|
||||
display_count = data.get("display_count", len(rows))
|
||||
truncated = data.get("truncated", False)
|
||||
|
||||
if not columns:
|
||||
return f" {DIM_CYAN}\u2192 Result (0 rows){RESET}"
|
||||
|
||||
# Build the summary line.
|
||||
parts = []
|
||||
if display_count < row_count:
|
||||
parts.append(f"showing {display_count} of {row_count}")
|
||||
else:
|
||||
parts.append(f"{row_count} rows")
|
||||
if truncated:
|
||||
parts.append("CSV truncated at limit")
|
||||
|
||||
csv_file = data.get("csv_file")
|
||||
download_line = ""
|
||||
if csv_file and _downloads_base_url:
|
||||
download_line = f"\n {DIM}\u2193 {_downloads_base_url}{csv_file}{RESET}"
|
||||
|
||||
# Try to fit the table in the terminal. If too wide, skip it —
|
||||
# the model's prose summary + download link are enough.
|
||||
try:
|
||||
term_width = os.get_terminal_size().columns
|
||||
except OSError:
|
||||
term_width = 120
|
||||
|
||||
widths = [len(str(c)) for c in columns]
|
||||
for row in rows:
|
||||
for i, val in enumerate(row):
|
||||
widths[i] = max(widths[i], len(str(val) if val is not None else "NULL"))
|
||||
|
||||
# 4 leading spaces + "| " between each col + trailing " |"
|
||||
table_width = 4 + sum(widths) + 3 * len(widths) + 1
|
||||
|
||||
if table_width > term_width:
|
||||
header = f" {DIM_CYAN}\u2192 Result ({row_count} rows) \u2014 too wide to print in terminal, download below{RESET}"
|
||||
return f"{header}{download_line}"
|
||||
|
||||
def fmt_row(vals: list[Any]) -> str:
|
||||
cells = []
|
||||
for v, w in zip(vals, widths, strict=False):
|
||||
cells.append(str(v if v is not None else "NULL").ljust(w))
|
||||
return " | " + " | ".join(cells) + " |"
|
||||
|
||||
lines = [fmt_row(columns)]
|
||||
lines.append(" |" + "|".join("-" * (w + 2) for w in widths) + "|")
|
||||
for row in rows:
|
||||
lines.append(fmt_row(row))
|
||||
|
||||
header = f" {DIM_CYAN}\u2192 Result ({', '.join(parts)})"
|
||||
table = "\n".join(lines)
|
||||
return f"{header}\n{table}{RESET}{download_line}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-turn REPL using Runner.run_streamed()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def run_turn(
|
||||
agent: SandboxAgent,
|
||||
conversation: list[Any],
|
||||
question: str,
|
||||
run_config: RunConfig,
|
||||
) -> list[Any]:
|
||||
"""Run one conversational turn and return the updated conversation history."""
|
||||
input_items = conversation + [{"role": "user", "content": question}]
|
||||
|
||||
result = Runner.run_streamed(agent, input_items, run_config=run_config)
|
||||
|
||||
async for event in result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
|
||||
print(event.data.delta, end="", flush=True)
|
||||
continue
|
||||
|
||||
if event.type != "run_item_stream_event":
|
||||
continue
|
||||
|
||||
if event.name == "tool_called":
|
||||
item = event.item
|
||||
raw = getattr(item, "raw_item", None)
|
||||
if raw is not None:
|
||||
name = getattr(raw, "name", "")
|
||||
arguments = getattr(raw, "arguments", "")
|
||||
print()
|
||||
print(_format_tool_args(name, arguments))
|
||||
continue
|
||||
|
||||
if event.name == "tool_output":
|
||||
item = event.item
|
||||
output = getattr(item, "output", "")
|
||||
if isinstance(output, str):
|
||||
formatted = _format_tool_result(output)
|
||||
if formatted is not None:
|
||||
print(formatted)
|
||||
print()
|
||||
continue
|
||||
|
||||
print()
|
||||
|
||||
# Build the full conversation history for the next turn using the SDK's
|
||||
# built-in conversion, which correctly serializes all item types.
|
||||
return result.to_input_list()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session state persistence for pause/resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_session_state() -> DaytonaSandboxSessionState | None:
|
||||
"""Load saved session state from disk, or return None."""
|
||||
if not SESSION_STATE_PATH.exists():
|
||||
return None
|
||||
try:
|
||||
return DaytonaSandboxSessionState.model_validate_json(SESSION_STATE_PATH.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_session_state(state: DaytonaSandboxSessionState) -> None:
|
||||
"""Persist session state to disk so the sandbox can be reused next run."""
|
||||
SESSION_STATE_PATH.write_text(state.model_dump_json(indent=2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
agent = build_agent()
|
||||
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[JsonlOutboxSink(AUDIT_LOG_PATH)],
|
||||
payload_policy=EventPayloadPolicy(include_exec_output=True),
|
||||
)
|
||||
RESULTS_PORT = 8080
|
||||
|
||||
client = DaytonaSandboxClient(instrumentation=instrumentation)
|
||||
client_options = DaytonaSandboxClientOptions(
|
||||
pause_on_exit=True,
|
||||
exposed_ports=(RESULTS_PORT,),
|
||||
)
|
||||
|
||||
# Try to resume a previously paused sandbox.
|
||||
saved_state = _load_session_state()
|
||||
sandbox = None
|
||||
destroy = False
|
||||
|
||||
try:
|
||||
if saved_state is not None:
|
||||
old_sandbox_id = saved_state.sandbox_id
|
||||
try:
|
||||
sandbox = await client.resume(saved_state)
|
||||
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
|
||||
if sandbox.state.sandbox_id == old_sandbox_id:
|
||||
print("Reconnected to existing sandbox.")
|
||||
else:
|
||||
print("Previous sandbox no longer exists. Created a new one.")
|
||||
except Exception as e:
|
||||
print(f"Could not resume previous sandbox: {e}")
|
||||
saved_state = None
|
||||
sandbox = None
|
||||
|
||||
if sandbox is None:
|
||||
sandbox = await client.create(manifest=agent.default_manifest, options=client_options)
|
||||
|
||||
await sandbox.start()
|
||||
|
||||
# Persist state immediately so crashes don't orphan the sandbox.
|
||||
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
|
||||
_save_session_state(sandbox.state)
|
||||
|
||||
# Build database inside sandbox (idempotent — skips if DB already exists).
|
||||
print("Setting up database (may take a few minutes on first run)...")
|
||||
result = await sandbox.exec("python3", "setup_db.py", timeout=1800.0)
|
||||
stdout = result.stdout.decode("utf-8", errors="replace")
|
||||
if stdout.strip():
|
||||
print(stdout)
|
||||
if not result.ok():
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
print(f"Database setup failed:\n{stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Start a file server in the sandbox so query results can be downloaded.
|
||||
await sandbox.exec("mkdir -p results", timeout=5.0)
|
||||
await sandbox.exec(
|
||||
f"nohup python3 -m http.server {RESULTS_PORT} --directory results > /dev/null 2>&1 &",
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
# Resolve the Daytona signed URL for the file server.
|
||||
global _downloads_base_url
|
||||
try:
|
||||
endpoint = await sandbox.resolve_exposed_port(RESULTS_PORT)
|
||||
_downloads_base_url = endpoint.url_for("http")
|
||||
except Exception as e:
|
||||
print(f" Warning: could not resolve download URL: {e}")
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name="NASA Spending Q&A",
|
||||
)
|
||||
|
||||
downloads_line = ""
|
||||
if _downloads_base_url:
|
||||
downloads_line = f"\n Browse results: {DIM_CYAN}{_downloads_base_url}{RESET}"
|
||||
|
||||
print(f"""
|
||||
{DIM}{"=" * 60}{RESET}
|
||||
NASA Spending Q&A (FY2021\u2013FY2025)
|
||||
|
||||
Data from USAspending.gov \u2014 contracts, grants, and IDVs
|
||||
awarded by NASA. Each row is a transaction (obligation).
|
||||
|
||||
Includes: amounts, award descriptions, recipients, recipient
|
||||
locations, places of performance, industry and product
|
||||
categories, sub-agencies, and fiscal years.
|
||||
{downloads_line}
|
||||
Type {DIM_CYAN}'exit'{RESET} to pause sandbox, {DIM_CYAN}'destroy'{RESET} to delete it.
|
||||
{DIM}{"=" * 60}{RESET}
|
||||
""")
|
||||
|
||||
conversation: list[Any] = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
question = input("> ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
cmd = question.strip().lower()
|
||||
if cmd == "exit":
|
||||
break
|
||||
if cmd == "destroy":
|
||||
destroy = True
|
||||
break
|
||||
|
||||
if not question.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
conversation = await run_turn(agent, conversation, question, run_config)
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
print()
|
||||
|
||||
if destroy:
|
||||
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
|
||||
sandbox.state.pause_on_exit = False
|
||||
SESSION_STATE_PATH.unlink(missing_ok=True)
|
||||
print("Deleting sandbox...")
|
||||
else:
|
||||
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
|
||||
_save_session_state(sandbox.state)
|
||||
print("Saving memory and pausing sandbox (can take a couple of minutes)...")
|
||||
|
||||
finally:
|
||||
if sandbox is not None:
|
||||
if destroy:
|
||||
# Skip memory flush — sandbox is being deleted.
|
||||
await sandbox.stop()
|
||||
await sandbox.shutdown()
|
||||
else:
|
||||
await sandbox.aclose()
|
||||
await client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
## Database: usaspending.db
|
||||
|
||||
NASA federal spending data from USAspending.gov. Each row is a single spending transaction (obligation or de-obligation) on a federal award.
|
||||
|
||||
### Table: spending
|
||||
|
||||
One row per transaction. Multiple transactions can share the same `award_id` (an award's initial obligation plus subsequent modifications, amendments, and de-obligations).
|
||||
|
||||
**Key columns:**
|
||||
- `award_id` — unique award identifier (many transactions share one award_id)
|
||||
- `award_piid_fain` — human-readable contract number (PIID) or assistance award number (FAIN)
|
||||
- `parent_award_piid` — parent IDV contract number (links task orders to their contract vehicle; contracts only)
|
||||
- `award_type` — 'contract', 'grant', 'idv', or 'other'
|
||||
- `action_date` — date of this transaction (YYYY-MM-DD)
|
||||
- `fiscal_year` — federal fiscal year (Oct-Sep; FY2024 = Oct 2023 - Sep 2024)
|
||||
- `federal_action_obligation` — dollar amount of this transaction (can be negative for de-obligations)
|
||||
- `total_obligation` — cumulative obligation for the entire award at time of this transaction
|
||||
- `base_and_all_options_value` — total potential ceiling value including unexercised options (contracts only)
|
||||
- `recipient_name` — who received the funds
|
||||
- `recipient_parent_name` — parent company (e.g., subsidiaries roll up; contracts only)
|
||||
- `recipient_state`, `recipient_city`, `recipient_country` — recipient location
|
||||
- `awarding_office` — NASA center/office that made the award (e.g., 'GODDARD SPACE FLIGHT CENTER', 'JET PROPULSION LABORATORY')
|
||||
- `funding_office` — NASA center/office providing funding (often same as awarding)
|
||||
- `naics_code`, `naics_description` — industry classification (primarily for contracts)
|
||||
- `psc_code`, `psc_description` — product/service classification
|
||||
- `place_of_performance_state`, `place_of_performance_city` — where work is performed
|
||||
- `period_of_perf_start`, `period_of_perf_end` — award period of performance dates (YYYY-MM-DD)
|
||||
- `extent_competed` — competition level: 'Full and Open Competition', 'Not Competed', etc. (contracts only)
|
||||
- `type_of_set_aside` — small business set-aside type: '8(a)', 'HUBZone', 'SDVOSB', etc. (contracts only)
|
||||
- `number_of_offers` — number of offers received (contracts only)
|
||||
- `contract_pricing_type` — pricing structure: 'Firm Fixed Price', 'Cost Plus', etc. (contracts only)
|
||||
- `business_types` — recipient type for assistance: nonprofit, university, state govt, etc. (grants only)
|
||||
- `description` — free-text description of the transaction
|
||||
|
||||
### Common query patterns
|
||||
|
||||
```sql
|
||||
-- Total spending by fiscal year
|
||||
SELECT fiscal_year, SUM(federal_action_obligation) AS total
|
||||
FROM spending GROUP BY fiscal_year ORDER BY fiscal_year;
|
||||
|
||||
-- Top recipients (roll up by parent company)
|
||||
SELECT COALESCE(NULLIF(recipient_parent_name, ''), recipient_name) AS entity,
|
||||
SUM(federal_action_obligation) AS total
|
||||
FROM spending GROUP BY entity ORDER BY total DESC LIMIT 10;
|
||||
|
||||
-- Spending by award type
|
||||
SELECT award_type, COUNT(*), SUM(federal_action_obligation) AS total
|
||||
FROM spending GROUP BY award_type;
|
||||
|
||||
-- Competitive vs sole-source contracts
|
||||
SELECT extent_competed, COUNT(DISTINCT award_id) AS awards,
|
||||
SUM(federal_action_obligation) AS total
|
||||
FROM spending WHERE award_type = 'contract'
|
||||
GROUP BY extent_competed ORDER BY total DESC;
|
||||
|
||||
-- Spending by NASA center
|
||||
SELECT awarding_office, SUM(federal_action_obligation) AS total
|
||||
FROM spending GROUP BY awarding_office ORDER BY total DESC;
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# spending
|
||||
|
||||
One row per prime award transaction from NASA. Each row represents a financial action — an initial obligation, modification, amendment, or de-obligation on a federal award.
|
||||
|
||||
## Columns
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| rowid | INTEGER PK | Auto-increment row identifier |
|
||||
| award_id | TEXT | Unique award identifier. Multiple rows share the same award_id when an award has multiple transactions |
|
||||
| award_piid_fain | TEXT | Human-readable award number: PIID for contracts (e.g., 'NNJ13ZBG001'), FAIN for assistance |
|
||||
| parent_award_piid | TEXT | Parent IDV contract number. Links task/delivery orders to their parent contract vehicle (contracts only) |
|
||||
| award_type | TEXT | Category: 'contract', 'grant', 'idv', or 'other' |
|
||||
| description | TEXT | Free-text description of the transaction or award purpose |
|
||||
| action_date | TEXT | Date of this transaction (ISO 8601: YYYY-MM-DD) |
|
||||
| fiscal_year | INTEGER | Federal fiscal year (Oct-Sep; FY2024 = Oct 2023 - Sep 2024) |
|
||||
| federal_action_obligation | REAL | Dollar amount of this specific transaction. Can be negative for de-obligations |
|
||||
| total_obligation | REAL | Cumulative obligation for the entire award at the time of this transaction |
|
||||
| base_and_all_options_value | REAL | Total potential ceiling value of the contract including all unexercised options. Contracts only; NULL for grants |
|
||||
| recipient_name | TEXT | Legal name of the recipient organization |
|
||||
| recipient_parent_name | TEXT | Parent company name (e.g., subsidiaries like 'Lockheed Martin Space' roll up to 'Lockheed Martin Corporation'). Contracts only; empty for grants |
|
||||
| recipient_state | TEXT | Two-letter US state code of recipient's address. Empty for foreign recipients |
|
||||
| recipient_city | TEXT | City of recipient's address |
|
||||
| recipient_country | TEXT | Country name (e.g., 'UNITED STATES', 'UNITED KINGDOM') |
|
||||
| awarding_office | TEXT | NASA center/office that made the award (e.g., 'GODDARD SPACE FLIGHT CENTER', 'JET PROPULSION LABORATORY'). Values are uppercase |
|
||||
| funding_office | TEXT | NASA center/office providing funding (often same as awarding). Values are uppercase |
|
||||
| naics_code | TEXT | North American Industry Classification System code. Primarily for contracts; may be empty for grants |
|
||||
| naics_description | TEXT | Human-readable NAICS description |
|
||||
| psc_code | TEXT | Product/Service Code for contracts, CFDA number for assistance. Different classification systems in the same column |
|
||||
| psc_description | TEXT | Human-readable description of the PSC (contracts) or CFDA program (assistance) |
|
||||
| place_of_performance_state | TEXT | State where work is performed. Two-letter codes for contracts, full names for assistance. May differ from recipient_state |
|
||||
| place_of_performance_city | TEXT | City where work is performed |
|
||||
| period_of_perf_start | TEXT | Award period of performance start date (YYYY-MM-DD) |
|
||||
| period_of_perf_end | TEXT | Award period of performance end date (YYYY-MM-DD). This is the current end date and may reflect extensions |
|
||||
| extent_competed | TEXT | Competition level. Values include 'Full and Open Competition', 'Not Available for Competition', 'Not Competed', etc. Contracts only; empty for grants |
|
||||
| type_of_set_aside | TEXT | Small business set-aside type. Values include 'Small Business Set-Aside', '8(a) Set-Aside', 'HUBZone Set-Aside', 'Service-Disabled Veteran-Owned Small Business Set-Aside', 'Women-Owned Small Business', etc. Contracts only |
|
||||
| number_of_offers | INTEGER | Number of offers/bids received. 1 = effectively sole-source even if technically competed. Contracts only; NULL for grants |
|
||||
| contract_pricing_type | TEXT | Pricing structure: 'Firm Fixed Price', 'Cost Plus Fixed Fee', 'Cost No Fee', 'Time and Materials', etc. Contracts only |
|
||||
| business_types | TEXT | Recipient organization type for assistance awards: nonprofit, university, state government, tribal, etc. Grants only; empty for contracts |
|
||||
|
||||
## Notes
|
||||
|
||||
- **Aggregating to award level**: use `GROUP BY award_id` with `SUM(federal_action_obligation)` to get total spending per award. The `total_obligation` column is a snapshot at each transaction and may not reflect the final total.
|
||||
- **Contract ceiling vs obligation**: `base_and_all_options_value` is the potential maximum; `total_obligation` is what's actually committed. A contract may have $10M obligated against a $500M ceiling.
|
||||
- **Parent company roll-up**: Use `COALESCE(NULLIF(recipient_parent_name, ''), recipient_name)` to group subsidiaries under their parent. Only populated for contracts.
|
||||
- **recipient_name** may vary slightly for the same entity across rows (e.g., 'BOEING CO' vs 'THE BOEING COMPANY'). Use `LIKE` or `UPPER()` for fuzzy matching.
|
||||
- **award_type** is derived from USAspending type codes: A/B/C/D -> 'contract', 02-05 -> 'grant', IDV_* -> 'idv'.
|
||||
- **federal_action_obligation** can be negative (de-obligations, corrections). Sum them to get net spending.
|
||||
- **naics_code** and **naics_description** are only populated for contracts; empty for grants/assistance.
|
||||
- **psc_code** contains Product/Service Codes for contracts and CFDA numbers for assistance awards. **psc_description** contains the corresponding description. These are different classification systems stored in the same column.
|
||||
- **Contracts-only columns**: `base_and_all_options_value`, `recipient_parent_name`, `parent_award_piid`, `extent_competed`, `type_of_set_aside`, `number_of_offers`, `contract_pricing_type` are only populated for contracts/IDVs.
|
||||
- **Grants-only columns**: `business_types` is only populated for assistance awards.
|
||||
@@ -0,0 +1,702 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download NASA spending data from USAspending.gov and build a SQLite database.
|
||||
|
||||
This script is designed to run inside a sandbox environment with only Python
|
||||
stdlib available. It fetches data via the USAspending bulk download API,
|
||||
parses the resulting CSVs, and creates a local SQLite database.
|
||||
|
||||
Usage:
|
||||
python setup_db.py [--force] [--start-fy 2021] [--end-fy 2025]
|
||||
|
||||
The script is idempotent: it skips the download/build if the database already
|
||||
exists unless --force is passed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import csv
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DB_DIR = Path("data")
|
||||
DB_PATH = DB_DIR / "usaspending.db"
|
||||
GLOSSARY_PATH = Path("schema") / "glossary.md"
|
||||
|
||||
USASPENDING_API = "https://api.usaspending.gov"
|
||||
BULK_DOWNLOAD_ENDPOINT = f"{USASPENDING_API}/api/v2/bulk_download/awards/"
|
||||
DOWNLOAD_STATUS_ENDPOINT = f"{USASPENDING_API}/api/v2/download/status"
|
||||
GLOSSARY_ENDPOINT = f"{USASPENDING_API}/api/v2/references/glossary/"
|
||||
|
||||
NASA_AGENCY = {
|
||||
"type": "awarding",
|
||||
"tier": "toptier",
|
||||
"name": "National Aeronautics and Space Administration",
|
||||
}
|
||||
|
||||
# Award type codes per the USAspending API contract.
|
||||
CONTRACT_CODES = ["A", "B", "C", "D"]
|
||||
GRANT_CODES = ["02", "03", "04", "05"]
|
||||
IDV_CODES = ["IDV_A", "IDV_B", "IDV_B_A", "IDV_B_B", "IDV_B_C", "IDV_C", "IDV_D", "IDV_E"]
|
||||
ALL_AWARD_CODES = CONTRACT_CODES + GRANT_CODES + IDV_CODES
|
||||
|
||||
AWARD_TYPE_MAP: dict[str, str] = {}
|
||||
for _code in CONTRACT_CODES:
|
||||
AWARD_TYPE_MAP[_code] = "contract"
|
||||
for _code in GRANT_CODES:
|
||||
AWARD_TYPE_MAP[_code] = "grant"
|
||||
for _code in IDV_CODES:
|
||||
AWARD_TYPE_MAP[_code] = "idv"
|
||||
|
||||
# Common headers — the USAspending WAF rejects requests without a User-Agent.
|
||||
_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "USAspending-setup/1.0 (universal_computer example)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS spending (
|
||||
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
award_id TEXT,
|
||||
award_piid_fain TEXT,
|
||||
parent_award_piid TEXT,
|
||||
award_type TEXT,
|
||||
description TEXT,
|
||||
action_date TEXT,
|
||||
fiscal_year INTEGER,
|
||||
federal_action_obligation REAL,
|
||||
total_obligation REAL,
|
||||
base_and_all_options_value REAL,
|
||||
recipient_name TEXT,
|
||||
recipient_parent_name TEXT,
|
||||
recipient_state TEXT,
|
||||
recipient_city TEXT,
|
||||
recipient_country TEXT,
|
||||
awarding_office TEXT,
|
||||
funding_office TEXT,
|
||||
naics_code TEXT,
|
||||
naics_description TEXT,
|
||||
psc_code TEXT,
|
||||
psc_description TEXT,
|
||||
place_of_performance_state TEXT,
|
||||
place_of_performance_city TEXT,
|
||||
period_of_perf_start TEXT,
|
||||
period_of_perf_end TEXT,
|
||||
extent_competed TEXT,
|
||||
type_of_set_aside TEXT,
|
||||
number_of_offers INTEGER,
|
||||
contract_pricing_type TEXT,
|
||||
business_types TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_award_id ON spending(award_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_fiscal_year ON spending(fiscal_year);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_award_type ON spending(award_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_recipient ON spending(recipient_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_recipient_parent ON spending(recipient_parent_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_state ON spending(recipient_state);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_action_date ON spending(action_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_naics ON spending(naics_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_obligation ON spending(federal_action_obligation);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_extent_competed ON spending(extent_competed);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_perf_start ON spending(period_of_perf_start);
|
||||
CREATE INDEX IF NOT EXISTS idx_spending_awarding_office ON spending(awarding_office);
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _urlopen_with_retry(
|
||||
req: urllib.request.Request, *, timeout: int = 60, retries: int = 3
|
||||
) -> bytes:
|
||||
"""urlopen with retries for the flaky USAspending endpoints."""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return bytes(resp.read())
|
||||
except (urllib.error.URLError, ConnectionError, OSError) as e:
|
||||
last_exc = e
|
||||
if attempt < retries:
|
||||
wait = 2**attempt
|
||||
print(f" Retry {attempt}/{retries} after error: {e} (waiting {wait}s)")
|
||||
time.sleep(wait)
|
||||
raise RuntimeError(f"Request failed after {retries} attempts: {last_exc}") from last_exc
|
||||
|
||||
|
||||
def api_post(url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""POST JSON to a USAspending API endpoint and return the parsed response."""
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers=_HEADERS, method="POST")
|
||||
body = _urlopen_with_retry(req)
|
||||
return json.loads(body.decode("utf-8")) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def api_get(url: str) -> dict[str, Any]:
|
||||
"""GET a USAspending API endpoint and return the parsed response."""
|
||||
req = urllib.request.Request(url, headers=_HEADERS)
|
||||
body = _urlopen_with_retry(req)
|
||||
return json.loads(body.decode("utf-8")) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bulk download
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def submit_bulk_download(
|
||||
award_types: list[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Submit a bulk download request and return (status_url, file_url).
|
||||
|
||||
The USAspending bulk download API requires:
|
||||
- filters.agencies: list of agency objects (name/tier/type)
|
||||
- filters.prime_award_types: list of award type codes
|
||||
- filters.date_type: "action_date" or "last_modified_date"
|
||||
- filters.date_range: {start_date, end_date} (max 1 year span)
|
||||
|
||||
This only submits the request — call poll_download_status() to wait for completion.
|
||||
"""
|
||||
payload = {
|
||||
"filters": {
|
||||
"agencies": [NASA_AGENCY],
|
||||
"prime_award_types": award_types,
|
||||
"date_type": "action_date",
|
||||
"date_range": {
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
},
|
||||
"file_format": "csv",
|
||||
}
|
||||
|
||||
resp = api_post(BULK_DOWNLOAD_ENDPOINT, payload)
|
||||
file_url = resp.get("file_url")
|
||||
status_url = resp.get("status_url")
|
||||
|
||||
if not status_url and not file_url:
|
||||
raise RuntimeError(f"Unexpected API response: {resp}")
|
||||
|
||||
return status_url, file_url
|
||||
|
||||
|
||||
def poll_download_status(status_url: str | None, file_url: str | None) -> str:
|
||||
"""Poll the download status endpoint until the file is ready."""
|
||||
if not status_url:
|
||||
if file_url:
|
||||
return file_url
|
||||
raise RuntimeError("No status_url or file_url to poll")
|
||||
|
||||
for attempt in range(120):
|
||||
try:
|
||||
status = api_get(status_url)
|
||||
except Exception:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
state = status.get("status", "unknown")
|
||||
if state == "finished":
|
||||
return status.get("file_url") or file_url or ""
|
||||
elif state == "failed":
|
||||
raise RuntimeError(f"Download generation failed: {status.get('message', 'unknown')}")
|
||||
|
||||
if attempt % 6 == 0:
|
||||
print(f" Generating... (status: {state})")
|
||||
time.sleep(5)
|
||||
|
||||
raise RuntimeError("Timed out waiting for download (10 minutes)")
|
||||
|
||||
|
||||
def download_and_extract(file_url: str, extract_dir: Path) -> list[Path]:
|
||||
"""Download a zip file and extract CSVs to extract_dir."""
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = extract_dir / "download.zip"
|
||||
|
||||
print(" Downloading...")
|
||||
req = urllib.request.Request(file_url, headers={"User-Agent": _HEADERS["User-Agent"]})
|
||||
data = _urlopen_with_retry(req, timeout=300, retries=3)
|
||||
zip_path.write_bytes(data)
|
||||
file_size_mb = len(data) / (1024 * 1024)
|
||||
print(f" Downloaded {file_size_mb:.1f} MB")
|
||||
|
||||
print(" Extracting CSV files...")
|
||||
csv_files = []
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".csv"):
|
||||
zf.extract(name, extract_dir)
|
||||
csv_files.append(extract_dir / name)
|
||||
print(f" {name}")
|
||||
|
||||
zip_path.unlink()
|
||||
return csv_files
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV ingestion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def safe_float(val: str) -> float | None:
|
||||
if not val or val.strip() == "":
|
||||
return None
|
||||
try:
|
||||
return float(val.replace(",", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def safe_int(val: str) -> int | None:
|
||||
if not val or val.strip() == "":
|
||||
return None
|
||||
try:
|
||||
return int(val.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def classify_award_type(type_code: str, award_id: str) -> str:
|
||||
mapped = AWARD_TYPE_MAP.get(type_code)
|
||||
if mapped:
|
||||
return mapped
|
||||
# Fallback: detect IDVs from the award_id prefix when the type code
|
||||
# doesn't match our expected IDV codes.
|
||||
if award_id.startswith("CONT_IDV_"):
|
||||
return "idv"
|
||||
return "other"
|
||||
|
||||
|
||||
def _detect_csv_type(headers: set[str]) -> str:
|
||||
"""Detect whether a CSV is contracts or assistance based on its headers.
|
||||
|
||||
Per the USAspending data dictionary, PrimeAwardUniqueKey is stored as
|
||||
'contract_award_unique_key' in contracts and 'assistance_award_unique_key'
|
||||
in assistance.
|
||||
"""
|
||||
if "contract_award_unique_key" in headers:
|
||||
return "contracts"
|
||||
if "assistance_award_unique_key" in headers:
|
||||
return "assistance"
|
||||
raise ValueError(
|
||||
"Cannot detect CSV type: neither 'contract_award_unique_key' nor "
|
||||
"'assistance_award_unique_key' found in headers"
|
||||
)
|
||||
|
||||
|
||||
# Column mappings per CSV type, derived from the USAspending data dictionary
|
||||
# (https://api.usaspending.gov/api/v2/references/data_dictionary/).
|
||||
#
|
||||
# "shared" columns have the same name in both contracts and assistance CSVs.
|
||||
# Type-specific columns are listed under "contracts" and "assistance".
|
||||
|
||||
# Column mappings verified against actual CSV headers downloaded from USAspending
|
||||
# on 2026-03-26, and cross-referenced with the data dictionary API at
|
||||
# https://api.usaspending.gov/api/v2/references/data_dictionary/.
|
||||
#
|
||||
# "shared" columns have the same name in both contracts and assistance CSVs.
|
||||
# Type-specific columns differ between the two and are listed separately.
|
||||
|
||||
_SHARED_COLUMNS = {
|
||||
# db_column -> csv_column
|
||||
"action_date": "action_date",
|
||||
"fiscal_year": "action_date_fiscal_year",
|
||||
"federal_action_obligation": "federal_action_obligation",
|
||||
"recipient_name": "recipient_name",
|
||||
"recipient_state": "recipient_state_code",
|
||||
"recipient_city": "recipient_city_name",
|
||||
"recipient_country": "recipient_country_name",
|
||||
"awarding_office": "awarding_office_name",
|
||||
"funding_office": "funding_office_name",
|
||||
"description": "transaction_description",
|
||||
"place_of_performance_city": "primary_place_of_performance_city_name",
|
||||
"period_of_perf_start": "period_of_performance_start_date",
|
||||
"period_of_perf_end": "period_of_performance_current_end_date",
|
||||
}
|
||||
|
||||
_TYPE_COLUMNS: dict[str, dict[str, str]] = {
|
||||
"contracts": {
|
||||
"award_id": "contract_award_unique_key",
|
||||
"award_piid_fain": "award_id_piid",
|
||||
"parent_award_piid": "parent_award_id_piid",
|
||||
"award_type_code": "award_type_code",
|
||||
"total_obligation": "total_dollars_obligated",
|
||||
"base_and_all_options_value": "base_and_all_options_value",
|
||||
"recipient_parent_name": "recipient_parent_name",
|
||||
"place_of_performance_state": "primary_place_of_performance_state_code",
|
||||
"naics_code": "naics_code",
|
||||
"naics_description": "naics_description",
|
||||
"psc_code": "product_or_service_code",
|
||||
"psc_description": "product_or_service_code_description",
|
||||
"extent_competed": "extent_competed",
|
||||
"type_of_set_aside": "type_of_set_aside",
|
||||
"number_of_offers": "number_of_offers_received",
|
||||
"contract_pricing_type": "type_of_contract_pricing",
|
||||
"business_types": "", # not present in contracts CSVs
|
||||
},
|
||||
"assistance": {
|
||||
"award_id": "assistance_award_unique_key",
|
||||
"award_piid_fain": "award_id_fain",
|
||||
"parent_award_piid": "", # not applicable to assistance
|
||||
"award_type_code": "assistance_type_code",
|
||||
"total_obligation": "total_obligated_amount",
|
||||
"base_and_all_options_value": "", # contracts only
|
||||
"recipient_parent_name": "", # contracts only
|
||||
"place_of_performance_state": "primary_place_of_performance_state_name",
|
||||
"naics_code": "", # not present in assistance CSVs
|
||||
"naics_description": "",
|
||||
"psc_code": "cfda_number",
|
||||
"psc_description": "cfda_title",
|
||||
"extent_competed": "", # contracts only
|
||||
"type_of_set_aside": "", # contracts only
|
||||
"number_of_offers": "", # contracts only
|
||||
"contract_pricing_type": "", # contracts only
|
||||
"business_types": "business_types_description",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def ingest_csv(db: sqlite3.Connection, csv_path: Path) -> int:
|
||||
"""Ingest a USAspending prime transactions CSV into the spending table."""
|
||||
count = 0
|
||||
|
||||
with open(csv_path, encoding="utf-8", errors="replace") as f:
|
||||
reader = csv.DictReader(f)
|
||||
if reader.fieldnames is None:
|
||||
return 0
|
||||
|
||||
headers = set(reader.fieldnames)
|
||||
csv_type = _detect_csv_type(headers)
|
||||
type_cols = _TYPE_COLUMNS[csv_type]
|
||||
|
||||
# Verify expected columns exist
|
||||
all_expected = dict(_SHARED_COLUMNS)
|
||||
all_expected.update(type_cols)
|
||||
missing = [
|
||||
db_col for db_col, csv_col in all_expected.items() if csv_col and csv_col not in headers
|
||||
]
|
||||
if missing:
|
||||
print(f" Warning: missing expected columns: {missing}")
|
||||
|
||||
award_id_col = type_cols["award_id"]
|
||||
award_type_col = type_cols["award_type_code"]
|
||||
|
||||
for row in reader:
|
||||
award_id = row.get(award_id_col, "")
|
||||
if not award_id:
|
||||
continue
|
||||
|
||||
type_code = row.get(award_type_col, "")
|
||||
award_type = classify_award_type(type_code, award_id)
|
||||
|
||||
def col(db_name: str, _row: dict[str, str] = row) -> str:
|
||||
"""Look up a value: type-specific columns first, then shared."""
|
||||
csv_col = type_cols.get(db_name) or _SHARED_COLUMNS.get(db_name, "")
|
||||
return _row.get(csv_col, "") if csv_col else ""
|
||||
|
||||
db.execute(
|
||||
"""INSERT INTO spending
|
||||
(award_id, award_piid_fain, parent_award_piid,
|
||||
award_type, description, action_date, fiscal_year,
|
||||
federal_action_obligation, total_obligation, base_and_all_options_value,
|
||||
recipient_name, recipient_parent_name,
|
||||
recipient_state, recipient_city, recipient_country,
|
||||
awarding_office, funding_office,
|
||||
naics_code, naics_description, psc_code, psc_description,
|
||||
place_of_performance_state, place_of_performance_city,
|
||||
period_of_perf_start, period_of_perf_end,
|
||||
extent_competed, type_of_set_aside, number_of_offers,
|
||||
contract_pricing_type, business_types)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
award_id,
|
||||
col("award_piid_fain"),
|
||||
col("parent_award_piid"),
|
||||
award_type,
|
||||
col("description"),
|
||||
col("action_date"),
|
||||
safe_int(col("fiscal_year")),
|
||||
safe_float(col("federal_action_obligation")),
|
||||
safe_float(col("total_obligation")),
|
||||
safe_float(col("base_and_all_options_value")),
|
||||
col("recipient_name"),
|
||||
col("recipient_parent_name"),
|
||||
col("recipient_state"),
|
||||
col("recipient_city"),
|
||||
col("recipient_country"),
|
||||
col("awarding_office"),
|
||||
col("funding_office"),
|
||||
col("naics_code"),
|
||||
col("naics_description"),
|
||||
col("psc_code"),
|
||||
col("psc_description"),
|
||||
col("place_of_performance_state"),
|
||||
col("place_of_performance_city"),
|
||||
col("period_of_perf_start"),
|
||||
col("period_of_perf_end"),
|
||||
col("extent_competed"),
|
||||
col("type_of_set_aside"),
|
||||
safe_int(col("number_of_offers")),
|
||||
col("contract_pricing_type"),
|
||||
col("business_types"),
|
||||
),
|
||||
)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def build_database(csv_files: list[Path]) -> None:
|
||||
"""Build the SQLite database from extracted CSV files."""
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Creating database at {DB_PATH}...")
|
||||
db = sqlite3.connect(str(DB_PATH))
|
||||
db.executescript(SCHEMA_SQL)
|
||||
|
||||
total = 0
|
||||
for csv_path in csv_files:
|
||||
print(f" Ingesting {csv_path.name}...")
|
||||
count = ingest_csv(db, csv_path)
|
||||
total += count
|
||||
print(f" {count:,} rows")
|
||||
|
||||
db.commit()
|
||||
|
||||
cursor = db.execute("SELECT COUNT(*) FROM spending")
|
||||
rows_stored = cursor.fetchone()[0]
|
||||
cursor = db.execute("SELECT COUNT(DISTINCT award_id) FROM spending")
|
||||
unique_awards = cursor.fetchone()[0]
|
||||
db.close()
|
||||
|
||||
db_size_mb = DB_PATH.stat().st_size / (1024 * 1024)
|
||||
print(f"\nDatabase built: {DB_PATH}")
|
||||
print(f" Rows: {rows_stored:,}")
|
||||
print(f" Unique awards: {unique_awards:,}")
|
||||
print(f" Size: {db_size_mb:.1f} MB")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Glossary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def fetch_glossary() -> None:
|
||||
"""Fetch the official USAspending glossary and write it to schema/glossary.md."""
|
||||
if GLOSSARY_PATH.exists():
|
||||
print(f"Glossary already exists at {GLOSSARY_PATH}, skipping.")
|
||||
return
|
||||
|
||||
GLOSSARY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("Fetching USAspending glossary...")
|
||||
try:
|
||||
resp = api_get(f"{GLOSSARY_ENDPOINT}?limit=500")
|
||||
except Exception as e:
|
||||
print(f" Warning: failed to fetch glossary: {e}")
|
||||
return
|
||||
|
||||
results = resp.get("results", [])
|
||||
if not results:
|
||||
print(" Warning: glossary API returned no results.")
|
||||
return
|
||||
|
||||
results.sort(key=lambda t: t.get("term", "").lower())
|
||||
|
||||
lines = [
|
||||
"# USAspending Glossary",
|
||||
"",
|
||||
"Official definitions from [USAspending.gov](https://www.usaspending.gov).",
|
||||
f"Retrieved automatically by setup_db.py ({len(results)} terms).",
|
||||
"",
|
||||
]
|
||||
|
||||
for entry in results:
|
||||
term = entry.get("term", "").strip()
|
||||
plain = (entry.get("plain") or "").strip()
|
||||
official = (entry.get("official") or "").strip()
|
||||
|
||||
if not term:
|
||||
continue
|
||||
|
||||
lines.append(f"## {term}")
|
||||
lines.append("")
|
||||
if plain:
|
||||
lines.append(plain)
|
||||
lines.append("")
|
||||
if official and official != plain:
|
||||
lines.append(f"**Official definition:** {official}")
|
||||
lines.append("")
|
||||
|
||||
GLOSSARY_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f" Wrote {len(results)} glossary terms to {GLOSSARY_PATH}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def fiscal_year_dates(fy: int) -> tuple[str, str]:
|
||||
"""Return (start_date, end_date) for a federal fiscal year.
|
||||
|
||||
Federal FY runs Oct 1 of the prior calendar year through Sep 30.
|
||||
Example: FY2024 = 2023-10-01 to 2024-09-30.
|
||||
"""
|
||||
return f"{fy - 1}-10-01", f"{fy}-09-30"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build NASA USAspending SQLite database")
|
||||
parser.add_argument("--force", action="store_true", help="Rebuild even if database exists")
|
||||
parser.add_argument(
|
||||
"--start-fy", type=int, default=2021, help="First fiscal year to download (default: 2021)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--end-fy", type=int, default=2025, help="Last fiscal year to download (default: 2025)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.start_fy > args.end_fy:
|
||||
parser.error(f"--start-fy ({args.start_fy}) must be <= --end-fy ({args.end_fy})")
|
||||
|
||||
requested_fys = set(range(args.start_fy, args.end_fy + 1))
|
||||
|
||||
if DB_PATH.exists() and not args.force:
|
||||
# Verify the existing DB covers all requested fiscal years.
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
|
||||
rows = conn.execute("SELECT DISTINCT fiscal_year FROM spending").fetchall()
|
||||
conn.close()
|
||||
present_fys = {int(r[0]) for r in rows if r[0] is not None}
|
||||
missing_fys = requested_fys - present_fys
|
||||
if not missing_fys:
|
||||
db_size_mb = DB_PATH.stat().st_size / (1024 * 1024)
|
||||
print(
|
||||
f"Database already exists at {DB_PATH} ({db_size_mb:.1f} MB) "
|
||||
f"with all requested FYs. Use --force to rebuild."
|
||||
)
|
||||
return
|
||||
print(
|
||||
f"Database exists but is missing FY data for: "
|
||||
f"{', '.join(str(fy) for fy in sorted(missing_fys))}. Rebuilding..."
|
||||
)
|
||||
except Exception:
|
||||
print("Database exists but could not be verified. Rebuilding...")
|
||||
DB_PATH.unlink()
|
||||
elif DB_PATH.exists():
|
||||
DB_PATH.unlink()
|
||||
|
||||
tmp_dir = Path("data/tmp_download")
|
||||
|
||||
print("=== NASA USAspending Database Builder ===")
|
||||
print(f"Fiscal years: {args.start_fy} - {args.end_fy}\n")
|
||||
|
||||
# The bulk download API limits date_range to 1 year, so we request
|
||||
# one fiscal year at a time. We submit all requests upfront so the
|
||||
# server-side assembly (the slow part) runs concurrently, then poll
|
||||
# and download the results.
|
||||
all_csv_files: list[Path] = []
|
||||
failed_fys: list[int] = []
|
||||
fiscal_years = list(range(args.start_fy, args.end_fy + 1))
|
||||
|
||||
# Phase 1: Submit all bulk download requests concurrently.
|
||||
print("Submitting download requests...")
|
||||
pending: dict[int, tuple[str | None, str | None]] = {}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(fiscal_years)) as pool:
|
||||
|
||||
def _submit(fy: int) -> tuple[int, str | None, str | None]:
|
||||
start_date, end_date = fiscal_year_dates(fy)
|
||||
status_url, file_url = submit_bulk_download(
|
||||
ALL_AWARD_CODES,
|
||||
start_date,
|
||||
end_date,
|
||||
)
|
||||
return fy, status_url, file_url
|
||||
|
||||
futures = {pool.submit(_submit, fy): fy for fy in fiscal_years}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
fy = futures[future]
|
||||
try:
|
||||
_, status_url, file_url = future.result()
|
||||
pending[fy] = (status_url, file_url)
|
||||
print(f" FY{fy}: submitted")
|
||||
except Exception as e:
|
||||
print(f" FY{fy}: submit failed: {e}")
|
||||
failed_fys.append(fy)
|
||||
|
||||
# Phase 2: Poll all pending requests until ready, then download.
|
||||
for fy in sorted(pending):
|
||||
print(f"\n--- FY{fy} ---")
|
||||
status_url, file_url = pending[fy]
|
||||
try:
|
||||
file_url = poll_download_status(status_url, file_url)
|
||||
print(f" Ready: {file_url}")
|
||||
fy_dir = tmp_dir / f"fy{fy}"
|
||||
csv_files = download_and_extract(file_url, fy_dir)
|
||||
all_csv_files.extend(csv_files)
|
||||
except Exception as e:
|
||||
print(f" Error: failed FY{fy}: {e}")
|
||||
failed_fys.append(fy)
|
||||
|
||||
if not all_csv_files:
|
||||
print("\nError: no data downloaded. Check internet connectivity.")
|
||||
sys.exit(1)
|
||||
|
||||
if failed_fys:
|
||||
print(
|
||||
f"\nError: failed to download data for: "
|
||||
f"{', '.join(f'FY{fy}' for fy in failed_fys)}. "
|
||||
f"Cannot build a complete database."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print("\n--- Fetching glossary ---")
|
||||
fetch_glossary()
|
||||
|
||||
print("\n--- Building database ---")
|
||||
build_database(all_csv_files)
|
||||
|
||||
# Verify the built DB covers all requested fiscal years.
|
||||
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
|
||||
rows = conn.execute("SELECT DISTINCT fiscal_year FROM spending").fetchall()
|
||||
conn.close()
|
||||
present_fys = {int(r[0]) for r in rows if r[0] is not None}
|
||||
missing_fys = requested_fys - present_fys
|
||||
if missing_fys:
|
||||
print(
|
||||
f"\nError: database built but missing data for: "
|
||||
f"{', '.join(f'FY{fy}' for fy in sorted(missing_fys))}. "
|
||||
f"Downloaded files may have been empty."
|
||||
)
|
||||
DB_PATH.unlink()
|
||||
sys.exit(1)
|
||||
|
||||
# Clean up temp files
|
||||
for f in tmp_dir.rglob("*"):
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
for d in sorted(tmp_dir.rglob("*"), reverse=True):
|
||||
if d.is_dir():
|
||||
d.rmdir()
|
||||
if tmp_dir.exists():
|
||||
tmp_dir.rmdir()
|
||||
|
||||
print("\nDone!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Any, Literal
|
||||
|
||||
from agents.sandbox import Capability, ExecTimeoutError, Manifest
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.tool import FunctionTool
|
||||
|
||||
# Python script executed inside the sandbox to run SQL queries safely.
|
||||
# Receives the query on stdin, enforces read-only mode and row limits.
|
||||
_QUERY_RUNNER_SCRIPT = r"""
|
||||
import csv, json, os, sqlite3, sys, time
|
||||
|
||||
db_path = sys.argv[1]
|
||||
display_limit = int(sys.argv[2])
|
||||
csv_limit = int(sys.argv[3])
|
||||
results_dir = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
|
||||
query = sys.stdin.read().strip()
|
||||
if not query:
|
||||
print("Error: empty query")
|
||||
sys.exit(0)
|
||||
|
||||
# Statement-level validation: only allow read-only operations
|
||||
first_token = query.lstrip().split()[0].upper() if query.strip() else ""
|
||||
if first_token not in ("SELECT", "WITH", "EXPLAIN", "PRAGMA"):
|
||||
print(f"Error: only SELECT, WITH, EXPLAIN, and PRAGMA statements are allowed (got {first_token})")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.execute("PRAGMA query_only = ON")
|
||||
cursor = conn.execute(query)
|
||||
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
||||
rows = cursor.fetchmany(csv_limit + 1)
|
||||
conn.close()
|
||||
except sqlite3.Error as e:
|
||||
print(f"SQL error: {e}")
|
||||
sys.exit(0)
|
||||
|
||||
if not columns:
|
||||
print(json.dumps({"columns": [], "rows": [], "row_count": 0, "truncated": False}))
|
||||
sys.exit(0)
|
||||
|
||||
csv_truncated = len(rows) > csv_limit
|
||||
if csv_truncated:
|
||||
rows = rows[:csv_limit]
|
||||
|
||||
# Save full result as CSV for download
|
||||
csv_file = ""
|
||||
if results_dir:
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
csv_file = f"query_{int(time.time())}_{os.getpid()}.csv"
|
||||
with open(os.path.join(results_dir, csv_file), "w", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(columns)
|
||||
writer.writerows(rows)
|
||||
|
||||
# Return only display_limit rows to the model, but report total counts
|
||||
total_rows = len(rows)
|
||||
display_rows = rows[:display_limit]
|
||||
|
||||
result = {
|
||||
"columns": columns,
|
||||
"rows": display_rows,
|
||||
"row_count": total_rows,
|
||||
"display_count": len(display_rows),
|
||||
"truncated": csv_truncated,
|
||||
}
|
||||
if csv_file:
|
||||
result["csv_file"] = csv_file
|
||||
if total_rows > len(display_rows):
|
||||
result["note"] = f"Showing {len(display_rows)} of {total_rows} rows. Full result saved to CSV."
|
||||
|
||||
print(json.dumps(result))
|
||||
"""
|
||||
|
||||
|
||||
def _shell_quote(s: str) -> str:
|
||||
"""Single-quote a string for safe shell interpolation."""
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
_SQL_CAPABILITY_INSTRUCTIONS = textwrap.dedent(
|
||||
"""\
|
||||
When querying the database:
|
||||
- Always use `run_sql` to execute SQL. Never run sqlite3 directly via a shell.
|
||||
- Write standard SQLite-compatible SQL.
|
||||
- Prefer aggregations (GROUP BY, SUM, COUNT, AVG) over returning many raw rows.
|
||||
- The display shows up to 100 rows, but up to 10,000 rows are saved to a downloadable CSV.
|
||||
If the user needs a large export, let them know the full result is available via the download link.
|
||||
- Use the schema documentation files in schema/tables/ if you need column details.
|
||||
- Read schema/glossary.md for official definitions of USAspending terms.
|
||||
- For monetary values, the database stores amounts in dollars as REAL values.
|
||||
"""
|
||||
).strip()
|
||||
|
||||
|
||||
def _make_run_sql_tool(
|
||||
session: BaseSandboxSession,
|
||||
db_path: str,
|
||||
max_display_rows: int,
|
||||
max_csv_rows: int,
|
||||
timeout_seconds: float,
|
||||
results_dir: str,
|
||||
) -> FunctionTool:
|
||||
"""Build a FunctionTool that executes read-only SQL inside the sandbox."""
|
||||
|
||||
async def run_sql(query: str, limit: int | None = None) -> str:
|
||||
"""Execute a read-only SQL query against the NASA USAspending SQLite database.
|
||||
|
||||
Returns results as JSON with columns, rows, row_count, and truncated fields.
|
||||
Results are also saved as a downloadable CSV. The display is limited to a
|
||||
small number of rows, but the CSV may contain many more.
|
||||
|
||||
Args:
|
||||
query: SQL SELECT query to execute against the USAspending database.
|
||||
Only read-only queries are allowed.
|
||||
limit: Optional display row limit override.
|
||||
"""
|
||||
display_limit = max(1, min(limit or max_display_rows, max_display_rows))
|
||||
|
||||
command = (
|
||||
f"printf '%s' {_shell_quote(query)} "
|
||||
f"| python3 -c {_shell_quote(_QUERY_RUNNER_SCRIPT)} "
|
||||
f"{_shell_quote(db_path)} {display_limit} {max_csv_rows}"
|
||||
f" {_shell_quote(results_dir)}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await session.exec(command, timeout=timeout_seconds)
|
||||
except (ExecTimeoutError, TimeoutError):
|
||||
return f"Query timed out after {timeout_seconds}s. Try a simpler query or add a LIMIT."
|
||||
|
||||
output = result.stdout.decode("utf-8", errors="replace")
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if not result.ok():
|
||||
return f"Execution error (exit {result.exit_code}):\n{stderr or output}"
|
||||
|
||||
return output.strip() if output.strip() else "Query returned no results."
|
||||
|
||||
from agents.tool import function_tool as _function_tool
|
||||
|
||||
return _function_tool(run_sql, name_override="run_sql")
|
||||
|
||||
|
||||
class SqlCapability(Capability):
|
||||
type: Literal["sql"] = "sql"
|
||||
db_path: str = "data/usaspending.db"
|
||||
max_display_rows: int = 100
|
||||
max_csv_rows: int = 10_000
|
||||
timeout_seconds: float = 30.0
|
||||
results_dir: str = "results"
|
||||
|
||||
def bind(self, session: BaseSandboxSession) -> None:
|
||||
self.session = session
|
||||
|
||||
def tools(self) -> list[Any]:
|
||||
if self.session is None:
|
||||
raise ValueError("SqlCapability is not bound to a SandboxSession")
|
||||
return [
|
||||
_make_run_sql_tool(
|
||||
session=self.session,
|
||||
db_path=self.db_path,
|
||||
max_display_rows=self.max_display_rows,
|
||||
max_csv_rows=self.max_csv_rows,
|
||||
timeout_seconds=self.timeout_seconds,
|
||||
results_dir=self.results_dir,
|
||||
)
|
||||
]
|
||||
|
||||
async def instructions(self, manifest: Manifest) -> str | None:
|
||||
return _SQL_CAPABILITY_INSTRUCTIONS
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Minimal E2B-backed sandbox example for manual validation.
|
||||
|
||||
This example is intentionally small: it creates a tiny workspace, lets the
|
||||
agent inspect it through one shell tool, and prints a short answer.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import (
|
||||
E2BSandboxClient,
|
||||
E2BSandboxClientOptions,
|
||||
E2BSandboxType,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
||||
raise SystemExit(
|
||||
"E2B sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra e2b"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
|
||||
DEFAULT_SANDBOX_TYPE = E2BSandboxType.E2B.value
|
||||
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
|
||||
SNAPSHOT_CHECK_CONTENT = "e2b snapshot round-trip ok\n"
|
||||
|
||||
|
||||
def _build_manifest() -> Manifest:
|
||||
return text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Renewal Notes\n\n"
|
||||
"This workspace contains a tiny account review packet for manual sandbox testing.\n"
|
||||
),
|
||||
"customer.md": (
|
||||
"# Customer\n\n"
|
||||
"- Name: Northwind Health.\n"
|
||||
"- Renewal date: 2026-04-15.\n"
|
||||
"- Risk: unresolved SSO setup.\n"
|
||||
),
|
||||
"next_steps.md": (
|
||||
"# Next steps\n\n"
|
||||
"1. Finish the SSO fix.\n"
|
||||
"2. Confirm legal language before procurement review.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_env(name: str) -> None:
|
||||
if os.environ.get(name):
|
||||
return
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
def _rewrite_template_resolution_error(exc: Exception) -> None:
|
||||
message = str(exc)
|
||||
marker = "error resolving template '"
|
||||
if marker not in message:
|
||||
return
|
||||
template = message.split(marker, 1)[1].split("'", 1)[0]
|
||||
raise SystemExit(
|
||||
f"E2B could not resolve template `{template}`.\n"
|
||||
"Pass `--template <your-template>` with a template that exists for this E2B account/team. "
|
||||
"If you were relying on the example default, the SDK default template for this backend is "
|
||||
"not available in your current E2B environment."
|
||||
) from exc
|
||||
|
||||
|
||||
async def _verify_stop_resume(
|
||||
*,
|
||||
sandbox_type: Literal["e2b_code_interpreter", "e2b"],
|
||||
template: str | None,
|
||||
timeout: int | None,
|
||||
pause_on_exit: bool,
|
||||
workspace_persistence: Literal["tar", "snapshot"],
|
||||
) -> None:
|
||||
client = E2BSandboxClient()
|
||||
with tempfile.TemporaryDirectory(prefix="e2b-snapshot-example-") as snapshot_dir:
|
||||
sandbox = await client.create(
|
||||
manifest=_build_manifest(),
|
||||
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
|
||||
options=E2BSandboxClientOptions(
|
||||
sandbox_type=E2BSandboxType(sandbox_type),
|
||||
template=template,
|
||||
timeout=timeout,
|
||||
pause_on_exit=pause_on_exit,
|
||||
workspace_persistence=workspace_persistence,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
await sandbox.write(
|
||||
SNAPSHOT_CHECK_PATH,
|
||||
io.BytesIO(SNAPSHOT_CHECK_CONTENT.encode("utf-8")),
|
||||
)
|
||||
await sandbox.stop()
|
||||
finally:
|
||||
await sandbox.shutdown()
|
||||
|
||||
resumed_sandbox = await client.resume(sandbox.state)
|
||||
try:
|
||||
await resumed_sandbox.start()
|
||||
restored = await resumed_sandbox.read(SNAPSHOT_CHECK_PATH)
|
||||
restored_text = restored.read()
|
||||
if isinstance(restored_text, bytes):
|
||||
restored_text = restored_text.decode("utf-8")
|
||||
if restored_text != SNAPSHOT_CHECK_CONTENT:
|
||||
raise RuntimeError(
|
||||
"Snapshot resume verification failed for "
|
||||
f"{sandbox_type!r}: expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}"
|
||||
)
|
||||
finally:
|
||||
await resumed_sandbox.shutdown()
|
||||
|
||||
print(f"snapshot round-trip ok ({sandbox_type}, {workspace_persistence})")
|
||||
|
||||
|
||||
async def main(
|
||||
*,
|
||||
model: str,
|
||||
question: str,
|
||||
sandbox_type: Literal["e2b_code_interpreter", "e2b"],
|
||||
template: str | None,
|
||||
timeout: int | None,
|
||||
pause_on_exit: bool,
|
||||
workspace_persistence: Literal["tar", "snapshot"],
|
||||
stream: bool,
|
||||
) -> None:
|
||||
_require_env("OPENAI_API_KEY")
|
||||
_require_env("E2B_API_KEY")
|
||||
|
||||
try:
|
||||
await _verify_stop_resume(
|
||||
sandbox_type=sandbox_type,
|
||||
template=template,
|
||||
timeout=timeout,
|
||||
pause_on_exit=pause_on_exit,
|
||||
workspace_persistence=workspace_persistence,
|
||||
)
|
||||
except Exception as exc:
|
||||
_rewrite_template_resolution_error(exc)
|
||||
raise
|
||||
|
||||
manifest = _build_manifest()
|
||||
agent = SandboxAgent(
|
||||
name="E2B Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the files before answering "
|
||||
"and keep the response concise. "
|
||||
"Do not invent files or statuses that are not present in the workspace. Cite the "
|
||||
"file names you inspected."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=E2BSandboxClient(),
|
||||
options=E2BSandboxClientOptions(
|
||||
sandbox_type=E2BSandboxType(sandbox_type),
|
||||
template=template,
|
||||
timeout=timeout,
|
||||
pause_on_exit=pause_on_exit,
|
||||
workspace_persistence=workspace_persistence,
|
||||
),
|
||||
),
|
||||
workflow_name="E2B sandbox example",
|
||||
)
|
||||
|
||||
if not stream:
|
||||
try:
|
||||
result = await Runner.run(agent, question, run_config=run_config)
|
||||
except Exception as exc:
|
||||
_rewrite_template_resolution_error(exc)
|
||||
raise
|
||||
print(result.final_output)
|
||||
return
|
||||
|
||||
try:
|
||||
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
|
||||
except Exception as exc:
|
||||
_rewrite_template_resolution_error(exc)
|
||||
raise
|
||||
saw_text_delta = False
|
||||
try:
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
except Exception as exc:
|
||||
_rewrite_template_resolution_error(exc)
|
||||
raise
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
parser.add_argument(
|
||||
"--sandbox-type",
|
||||
default=DEFAULT_SANDBOX_TYPE,
|
||||
choices=[member.value for member in E2BSandboxType],
|
||||
help=(
|
||||
"E2B sandbox interface to create. `e2b` provides a bash-style interface; "
|
||||
"`e2b_code_interpreter` provides a Jupyter-style interface."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--template", default=None, help="Optional E2B template name.")
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Optional E2B sandbox timeout in seconds.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pause-on-exit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Pause the sandbox on shutdown instead of killing it.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workspace-persistence",
|
||||
default="tar",
|
||||
choices=["tar", "snapshot"],
|
||||
help="Workspace persistence mode for the E2B sandbox.",
|
||||
)
|
||||
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
model=args.model,
|
||||
question=args.question,
|
||||
sandbox_type=args.sandbox_type,
|
||||
template=args.template,
|
||||
timeout=args.timeout,
|
||||
pause_on_exit=args.pause_on_exit,
|
||||
workspace_persistence=args.workspace_persistence,
|
||||
stream=args.stream,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Minimal Modal-backed sandbox example for manual validation.
|
||||
|
||||
This example mirrors the local and Docker sandbox demos, but it sends the
|
||||
workspace to a Modal sandbox.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.entries import GCSMount, Mount, S3Mount
|
||||
from agents.sandbox.session import BaseSandboxSession
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import (
|
||||
ModalCloudBucketMountStrategy,
|
||||
ModalSandboxClient,
|
||||
ModalSandboxClientOptions,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
||||
raise SystemExit(
|
||||
"Modal sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra modal"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
|
||||
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
|
||||
SNAPSHOT_CHECK_CONTENT = "modal snapshot round-trip ok\n"
|
||||
MOUNT_CHECK_FILENAME = "native-cloud-bucket-check.txt"
|
||||
MOUNT_CHECK_CONTENT = "modal native cloud bucket read/write ok\n"
|
||||
MOUNT_CHECK_UPDATED_CONTENT = "modal native cloud bucket read/write ok after resume\n"
|
||||
|
||||
|
||||
def _build_manifest(
|
||||
*,
|
||||
native_cloud_bucket_name: str | None = None,
|
||||
native_cloud_bucket_provider: Literal["s3", "gcs-hmac"] = "s3",
|
||||
native_cloud_bucket_mount_path: str | None = None,
|
||||
native_cloud_bucket_endpoint_url: str | None = None,
|
||||
native_cloud_bucket_key_prefix: str | None = None,
|
||||
native_cloud_bucket_secret_name: str | None = None,
|
||||
) -> Manifest:
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Modal Demo Workspace\n\n"
|
||||
"This workspace exists to validate the Modal sandbox backend manually.\n"
|
||||
),
|
||||
"incident.md": (
|
||||
"# Incident\n\n"
|
||||
"- Customer: Fabrikam Retail.\n"
|
||||
"- Issue: delayed reporting rollout.\n"
|
||||
"- Primary blocker: incomplete security questionnaire.\n"
|
||||
),
|
||||
"plan.md": (
|
||||
"# Plan\n\n"
|
||||
"1. Close the questionnaire.\n"
|
||||
"2. Reconfirm the rollout date with the customer.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
if native_cloud_bucket_name is None:
|
||||
return manifest
|
||||
|
||||
mount_path = (
|
||||
Path(native_cloud_bucket_mount_path) if native_cloud_bucket_mount_path is not None else None
|
||||
)
|
||||
mount_strategy = ModalCloudBucketMountStrategy(
|
||||
secret_name=native_cloud_bucket_secret_name,
|
||||
)
|
||||
if native_cloud_bucket_provider == "gcs-hmac":
|
||||
manifest.entries["cloud-bucket"] = GCSMount(
|
||||
bucket=native_cloud_bucket_name,
|
||||
access_id=(
|
||||
None
|
||||
if native_cloud_bucket_secret_name is not None
|
||||
else (
|
||||
os.environ.get("GCS_HMAC_ACCESS_KEY_ID")
|
||||
or os.environ.get("GOOGLE_ACCESS_KEY_ID")
|
||||
)
|
||||
),
|
||||
secret_access_key=(
|
||||
None
|
||||
if native_cloud_bucket_secret_name is not None
|
||||
else (
|
||||
os.environ.get("GCS_HMAC_SECRET_ACCESS_KEY")
|
||||
or os.environ.get("GOOGLE_ACCESS_KEY_SECRET")
|
||||
)
|
||||
),
|
||||
endpoint_url=native_cloud_bucket_endpoint_url,
|
||||
prefix=native_cloud_bucket_key_prefix,
|
||||
mount_path=mount_path,
|
||||
read_only=False,
|
||||
mount_strategy=mount_strategy,
|
||||
)
|
||||
else:
|
||||
manifest.entries["cloud-bucket"] = S3Mount(
|
||||
bucket=native_cloud_bucket_name,
|
||||
access_key_id=(
|
||||
None
|
||||
if native_cloud_bucket_secret_name is not None
|
||||
else os.environ.get("AWS_ACCESS_KEY_ID")
|
||||
),
|
||||
secret_access_key=(
|
||||
None
|
||||
if native_cloud_bucket_secret_name is not None
|
||||
else os.environ.get("AWS_SECRET_ACCESS_KEY")
|
||||
),
|
||||
session_token=(
|
||||
None
|
||||
if native_cloud_bucket_secret_name is not None
|
||||
else os.environ.get("AWS_SESSION_TOKEN")
|
||||
),
|
||||
endpoint_url=native_cloud_bucket_endpoint_url,
|
||||
prefix=native_cloud_bucket_key_prefix,
|
||||
mount_path=mount_path,
|
||||
read_only=False,
|
||||
mount_strategy=mount_strategy,
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def _native_cloud_bucket_mount_path(manifest: Manifest) -> Path | None:
|
||||
entry = manifest.entries.get("cloud-bucket")
|
||||
if not isinstance(entry, Mount):
|
||||
return None
|
||||
if entry.mount_path is None:
|
||||
return Path(manifest.root) / "cloud-bucket"
|
||||
if entry.mount_path.is_absolute():
|
||||
return entry.mount_path
|
||||
return Path(manifest.root) / entry.mount_path
|
||||
|
||||
|
||||
async def _read_text(session: BaseSandboxSession, path: Path) -> str:
|
||||
data = await session.read(path)
|
||||
text = cast(str | bytes, data.read())
|
||||
if isinstance(text, bytes):
|
||||
return text.decode("utf-8")
|
||||
return text
|
||||
|
||||
|
||||
def _require_env(name: str) -> None:
|
||||
if os.environ.get(name):
|
||||
return
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
async def _verify_stop_resume(
|
||||
*,
|
||||
manifest: Manifest,
|
||||
app_name: str,
|
||||
workspace_persistence: Literal["tar", "snapshot_filesystem", "snapshot_directory"],
|
||||
sandbox_create_timeout_s: float | None,
|
||||
) -> None:
|
||||
client = ModalSandboxClient()
|
||||
mount_path = _native_cloud_bucket_mount_path(manifest)
|
||||
mount_check_path = mount_path / MOUNT_CHECK_FILENAME if mount_path is not None else None
|
||||
options = ModalSandboxClientOptions(
|
||||
app_name=app_name,
|
||||
workspace_persistence=workspace_persistence,
|
||||
sandbox_create_timeout_s=sandbox_create_timeout_s,
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="modal-snapshot-example-") as snapshot_dir:
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
|
||||
options=options,
|
||||
)
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
await sandbox.write(
|
||||
SNAPSHOT_CHECK_PATH,
|
||||
io.BytesIO(SNAPSHOT_CHECK_CONTENT.encode("utf-8")),
|
||||
)
|
||||
await sandbox.stop()
|
||||
finally:
|
||||
await sandbox.shutdown()
|
||||
|
||||
resumed_sandbox = await client.resume(sandbox.state)
|
||||
try:
|
||||
await resumed_sandbox.start()
|
||||
restored_text = await _read_text(resumed_sandbox, SNAPSHOT_CHECK_PATH)
|
||||
if restored_text != SNAPSHOT_CHECK_CONTENT:
|
||||
raise RuntimeError(
|
||||
f"Snapshot resume verification failed for {workspace_persistence!r}: "
|
||||
f"expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}"
|
||||
)
|
||||
finally:
|
||||
await resumed_sandbox.aclose()
|
||||
|
||||
print(f"native cloud bucket read/write ok ({mount_check_path})")
|
||||
print(f"snapshot round-trip ok ({workspace_persistence})")
|
||||
|
||||
|
||||
async def main(
|
||||
*,
|
||||
model: str,
|
||||
question: str,
|
||||
app_name: str,
|
||||
workspace_persistence: Literal["tar", "snapshot_filesystem", "snapshot_directory"],
|
||||
sandbox_create_timeout_s: float | None,
|
||||
native_cloud_bucket_name: str | None,
|
||||
native_cloud_bucket_provider: Literal["s3", "gcs-hmac"],
|
||||
native_cloud_bucket_mount_path: str,
|
||||
native_cloud_bucket_endpoint_url: str | None,
|
||||
native_cloud_bucket_key_prefix: str | None,
|
||||
native_cloud_bucket_secret_name: str | None,
|
||||
stream: bool,
|
||||
) -> None:
|
||||
_require_env("OPENAI_API_KEY")
|
||||
manifest = _build_manifest(
|
||||
native_cloud_bucket_name=native_cloud_bucket_name,
|
||||
native_cloud_bucket_provider=native_cloud_bucket_provider,
|
||||
native_cloud_bucket_mount_path=native_cloud_bucket_mount_path,
|
||||
native_cloud_bucket_endpoint_url=native_cloud_bucket_endpoint_url,
|
||||
native_cloud_bucket_key_prefix=native_cloud_bucket_key_prefix,
|
||||
native_cloud_bucket_secret_name=native_cloud_bucket_secret_name,
|
||||
)
|
||||
|
||||
await _verify_stop_resume(
|
||||
manifest=manifest,
|
||||
app_name=app_name,
|
||||
workspace_persistence=workspace_persistence,
|
||||
sandbox_create_timeout_s=sandbox_create_timeout_s,
|
||||
)
|
||||
|
||||
agent = SandboxAgent(
|
||||
name="Modal Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the files before answering "
|
||||
"and keep the response concise. "
|
||||
"Do not invent files or statuses that are not present in the workspace. Cite the "
|
||||
"file names you inspected."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=ModalSandboxClient(),
|
||||
options=ModalSandboxClientOptions(
|
||||
app_name=app_name,
|
||||
workspace_persistence=workspace_persistence,
|
||||
sandbox_create_timeout_s=sandbox_create_timeout_s,
|
||||
),
|
||||
),
|
||||
workflow_name="Modal sandbox example",
|
||||
)
|
||||
|
||||
if not stream:
|
||||
result = await Runner.run(agent, question, run_config=run_config)
|
||||
print(result.final_output)
|
||||
return
|
||||
|
||||
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
|
||||
saw_text_delta = False
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
parser.add_argument(
|
||||
"--app-name",
|
||||
default="openai-agents-python-sandbox-example",
|
||||
help="Modal app name to create or reuse for the sandbox.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workspace-persistence",
|
||||
default="tar",
|
||||
choices=["tar", "snapshot_filesystem", "snapshot_directory"],
|
||||
help="Workspace persistence mode for the Modal sandbox.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sandbox-create-timeout-s",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Optional timeout for creating the Modal sandbox.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-name",
|
||||
default=None,
|
||||
help="Optional cloud bucket name to mount with ModalCloudBucketMountStrategy.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-provider",
|
||||
default="s3",
|
||||
choices=["s3", "gcs-hmac"],
|
||||
help="Provider type for --native-cloud-bucket-name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-mount-path",
|
||||
default="cloud-bucket",
|
||||
help=(
|
||||
"Mount path for --native-cloud-bucket-name. Relative paths are resolved under the "
|
||||
"workspace root."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-endpoint-url",
|
||||
default=None,
|
||||
help="Optional endpoint URL for --native-cloud-bucket-name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-key-prefix",
|
||||
default=None,
|
||||
help="Optional key prefix for --native-cloud-bucket-name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--native-cloud-bucket-secret-name",
|
||||
default=None,
|
||||
help=(
|
||||
"Optional named Modal Secret to use for --native-cloud-bucket-name instead of "
|
||||
"reading raw credentials from environment variables."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
model=args.model,
|
||||
question=args.question,
|
||||
app_name=args.app_name,
|
||||
workspace_persistence=args.workspace_persistence,
|
||||
sandbox_create_timeout_s=args.sandbox_create_timeout_s,
|
||||
native_cloud_bucket_name=args.native_cloud_bucket_name,
|
||||
native_cloud_bucket_provider=args.native_cloud_bucket_provider,
|
||||
native_cloud_bucket_mount_path=args.native_cloud_bucket_mount_path,
|
||||
native_cloud_bucket_endpoint_url=args.native_cloud_bucket_endpoint_url,
|
||||
native_cloud_bucket_key_prefix=args.native_cloud_bucket_key_prefix,
|
||||
native_cloud_bucket_secret_name=args.native_cloud_bucket_secret_name,
|
||||
stream=args.stream,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,995 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agents import Agent, ModelSettings, Runner, function_tool
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest, tool_call_name
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import (
|
||||
DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT,
|
||||
DEFAULT_RUNLOOP_WORKSPACE_ROOT,
|
||||
RunloopAfterIdle,
|
||||
RunloopGatewaySpec,
|
||||
RunloopLaunchParameters,
|
||||
RunloopMcpSpec,
|
||||
RunloopSandboxClient,
|
||||
RunloopSandboxClientOptions,
|
||||
RunloopSandboxSessionState,
|
||||
RunloopTunnelConfig,
|
||||
RunloopUserParameters,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
||||
raise SystemExit(
|
||||
"Runloop sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra runloop"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_MODEL = "gpt-5.4"
|
||||
DEFAULT_HTTP_PORT = 8123
|
||||
DEFAULT_AGENT_PROMPT = (
|
||||
"Inspect this Runloop sandbox workspace, verify the configuration using the shell tool, "
|
||||
"and summarize which Runloop-specific capabilities were exercised."
|
||||
)
|
||||
EXAMPLE_RESOURCE_SLUG = "runloop-capabilities-example"
|
||||
PERSISTENT_SECRET_NAME = "RUNLOOP_CAPABILITIES_EXAMPLE_TOKEN"
|
||||
PERSISTENT_SECRET_VALUE = "runloop-capabilities-example-token"
|
||||
PERSISTENT_NETWORK_POLICY_NAME = "runloop-capabilities-example-policy"
|
||||
HTTP_LOG_PATH = Path(".runloop-http.log")
|
||||
RUNTIME_CONTEXT_PATH = Path("runtime_context.json")
|
||||
AGENT_PROOF_PATH = Path("verification/agent-proof.txt")
|
||||
|
||||
|
||||
class RunloopResourceQueryResult(BaseModel):
|
||||
resource_type: Literal["secret", "network_policy"]
|
||||
name: str
|
||||
found: bool
|
||||
id: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class RunloopResourceBootstrapResult(BaseModel):
|
||||
resource_type: Literal["secret", "network_policy"]
|
||||
name: str
|
||||
action: Literal["created", "reused", "override"]
|
||||
id: str | None = None
|
||||
found_before_bootstrap: bool
|
||||
|
||||
|
||||
def _phase(title: str) -> None:
|
||||
print(f"\n=== {title} ===", flush=True)
|
||||
|
||||
|
||||
def _require_env(name: str) -> None:
|
||||
if os.environ.get(name):
|
||||
return
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
def _run_id() -> str:
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
|
||||
def _summarize_resource(item: object, fields: tuple[str, ...]) -> dict[str, object]:
|
||||
summary: dict[str, object] = {}
|
||||
for field in fields:
|
||||
value = getattr(item, field, None)
|
||||
if value is not None:
|
||||
summary[field] = value
|
||||
return summary
|
||||
|
||||
|
||||
async def _collect_async_items(items: Any, *, limit: int) -> list[Any]:
|
||||
collected: list[Any] = []
|
||||
async for item in items:
|
||||
collected.append(item)
|
||||
if len(collected) >= limit:
|
||||
break
|
||||
return collected
|
||||
|
||||
|
||||
def _status_code(exc: BaseException) -> int | None:
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
if isinstance(status_code, int):
|
||||
return status_code
|
||||
response = getattr(exc, "response", None)
|
||||
response_status = getattr(response, "status_code", None)
|
||||
return response_status if isinstance(response_status, int) else None
|
||||
|
||||
|
||||
def _is_not_found(exc: BaseException) -> bool:
|
||||
return _status_code(exc) == 404
|
||||
|
||||
|
||||
def _error_message(exc: BaseException) -> str | None:
|
||||
message = getattr(exc, "message", None)
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
body = getattr(exc, "body", None)
|
||||
if isinstance(body, dict):
|
||||
body_message = body.get("message")
|
||||
if isinstance(body_message, str):
|
||||
return body_message
|
||||
return None
|
||||
|
||||
|
||||
def _is_conflict(exc: BaseException) -> bool:
|
||||
status_code = _status_code(exc)
|
||||
if status_code == 409:
|
||||
return True
|
||||
if status_code == 400:
|
||||
message = _error_message(exc)
|
||||
return isinstance(message, str) and "already exists" in message.lower()
|
||||
return False
|
||||
|
||||
|
||||
async def _collect_maybe_async_items(items: Any, *, limit: int) -> list[Any]:
|
||||
if hasattr(items, "__aiter__"):
|
||||
return await _collect_async_items(items, limit=limit)
|
||||
return list(items)[:limit]
|
||||
|
||||
|
||||
async def _read_text(session: Any, path: Path) -> str:
|
||||
data = await session.read(path)
|
||||
try:
|
||||
payload = data.read()
|
||||
finally:
|
||||
data.close()
|
||||
if isinstance(payload, bytes):
|
||||
return payload.decode("utf-8")
|
||||
return str(payload)
|
||||
|
||||
|
||||
async def _write_json(session: Any, path: Path, payload: dict[str, object]) -> None:
|
||||
await session.write(
|
||||
path, io.BytesIO(json.dumps(payload, indent=2, sort_keys=True).encode("utf-8"))
|
||||
)
|
||||
|
||||
|
||||
def _build_manifest(*, workspace_root: str, context: dict[str, object]) -> Manifest:
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Runloop Capabilities Example\n\n"
|
||||
"This workspace is used to validate the Runloop-specific sandbox integration end "
|
||||
"to end.\n"
|
||||
),
|
||||
"checklist.md": (
|
||||
"# Checklist\n\n"
|
||||
"1. Inspect the workspace.\n"
|
||||
"2. Verify the resource discovery results in the context files.\n"
|
||||
"3. Confirm the managed secret is available without printing its full value.\n"
|
||||
"4. Confirm the HTTP preview server and verification file.\n"
|
||||
"5. Summarize what Runloop-native features were exercised and whether persistent "
|
||||
"resources were reused or created.\n"
|
||||
),
|
||||
"platform_context.json": json.dumps(context, indent=2, sort_keys=True) + "\n",
|
||||
}
|
||||
)
|
||||
return Manifest(root=workspace_root, entries=manifest.entries)
|
||||
|
||||
|
||||
def _build_sandbox_agent(
|
||||
*, model: str, manifest: Manifest, managed_secret_name: str
|
||||
) -> SandboxAgent:
|
||||
return SandboxAgent(
|
||||
name="Runloop Capabilities Guide",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Inspect the Runloop sandbox workspace carefully before answering. Use the shell tool "
|
||||
"to verify what happened in the environment and keep the final response concise. "
|
||||
"Follow this sequence:\n"
|
||||
"1. Run `pwd` and `find . -maxdepth 3 -type f | sort`.\n"
|
||||
"2. Read `README.md`, `checklist.md`, `platform_context.json`, and `runtime_context.json`.\n"
|
||||
"3. Report whether the managed secret and network policy existed before bootstrap by "
|
||||
"reading the query/bootstrap summaries from the context files.\n"
|
||||
f"4. Confirm whether `${managed_secret_name}` is set, but never print the full value. "
|
||||
"Only report whether it exists and its character length.\n"
|
||||
f"5. Read `{HTTP_LOG_PATH.as_posix()}` and confirm the HTTP server started.\n"
|
||||
f"6. Create `{AGENT_PROOF_PATH.as_posix()}` with these exact lines:\n"
|
||||
" runloop_capabilities_verified=true\n"
|
||||
" managed_secret_checked=true\n"
|
||||
" tunnel_verified=true\n"
|
||||
"7. Print that verification file from the shell.\n"
|
||||
"8. Final answer: 2 short sentences naming the specific Runloop features exercised, "
|
||||
"including whether the persistent secret and policy were reused or created.\n"
|
||||
"Only mention facts you verified from files, environment inspection, or shell output."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
|
||||
def _build_query_agent(
|
||||
*,
|
||||
model: str,
|
||||
query_secret_tool: Any,
|
||||
query_policy_tool: Any,
|
||||
managed_secret_name: str,
|
||||
network_policy_name: str,
|
||||
) -> Agent:
|
||||
return Agent(
|
||||
name="Runloop Resource Discovery Guide",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Use the provided Runloop query tools to check whether the persistent example "
|
||||
"resources already exist before any create step. Keep the final answer concise."
|
||||
),
|
||||
tools=[query_secret_tool, query_policy_tool],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
).clone(
|
||||
instructions=(
|
||||
"Use the provided Runloop query tools to check whether the persistent example "
|
||||
"resources already exist before any create step. Keep the final answer concise."
|
||||
),
|
||||
handoff_description=None,
|
||||
output_type=None,
|
||||
)
|
||||
|
||||
|
||||
def _stream_event_banner(event_name: str) -> str | None:
|
||||
if event_name == "tool_called":
|
||||
return "[tool call]"
|
||||
if event_name == "tool_output":
|
||||
return "[tool output]"
|
||||
return None
|
||||
|
||||
|
||||
def _runloop_state(session: Any) -> RunloopSandboxSessionState:
|
||||
return cast(RunloopSandboxSessionState, session.state)
|
||||
|
||||
|
||||
async def _run_plain_agent(
|
||||
*,
|
||||
agent: Agent,
|
||||
prompt: str,
|
||||
workflow_name: str,
|
||||
stream: bool,
|
||||
) -> str:
|
||||
if not stream:
|
||||
result = await Runner.run(agent, prompt, run_config=RunConfig(workflow_name=workflow_name))
|
||||
print(result.final_output)
|
||||
return str(result.final_output)
|
||||
|
||||
stream_result = Runner.run_streamed(
|
||||
agent,
|
||||
prompt,
|
||||
run_config=RunConfig(workflow_name=workflow_name),
|
||||
)
|
||||
saw_text_delta = False
|
||||
saw_any_text = False
|
||||
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
saw_any_text = True
|
||||
continue
|
||||
|
||||
if event.type != "run_item_stream_event":
|
||||
continue
|
||||
|
||||
banner = _stream_event_banner(event.name)
|
||||
if banner is None:
|
||||
continue
|
||||
if saw_text_delta:
|
||||
print()
|
||||
saw_text_delta = False
|
||||
print(f"{banner}: {tool_call_name(event.item.raw_item) or 'tool'}", flush=True)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
if not saw_any_text:
|
||||
print(stream_result.final_output)
|
||||
return str(stream_result.final_output)
|
||||
|
||||
|
||||
async def _run_sandbox_agent(
|
||||
*,
|
||||
agent: SandboxAgent,
|
||||
prompt: str,
|
||||
session: Any,
|
||||
workflow_name: str,
|
||||
stream: bool,
|
||||
) -> str:
|
||||
if not stream:
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
prompt,
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=session),
|
||||
workflow_name=workflow_name,
|
||||
),
|
||||
)
|
||||
print(result.final_output)
|
||||
return str(result.final_output)
|
||||
|
||||
stream_result = Runner.run_streamed(
|
||||
agent,
|
||||
prompt,
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=session),
|
||||
workflow_name=workflow_name,
|
||||
),
|
||||
)
|
||||
saw_text_delta = False
|
||||
saw_any_text = False
|
||||
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
saw_any_text = True
|
||||
continue
|
||||
|
||||
if event.type != "run_item_stream_event":
|
||||
continue
|
||||
|
||||
banner = _stream_event_banner(event.name)
|
||||
if banner is None:
|
||||
continue
|
||||
if saw_text_delta:
|
||||
print()
|
||||
saw_text_delta = False
|
||||
print(f"{banner}: {tool_call_name(event.item.raw_item) or 'tool'}", flush=True)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
if not saw_any_text:
|
||||
print(stream_result.final_output)
|
||||
return str(stream_result.final_output)
|
||||
|
||||
|
||||
async def _start_http_server(session: Any, *, port: int, workspace_root: str) -> None:
|
||||
command = (
|
||||
"python -m http.server "
|
||||
f"{port} --bind 0.0.0.0 --directory {workspace_root} "
|
||||
f"> {HTTP_LOG_PATH.as_posix()} 2>&1 &"
|
||||
)
|
||||
result = await session.exec(command, shell=True, timeout=10)
|
||||
if not result.ok():
|
||||
raise RuntimeError(result.stderr.decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def _build_endpoint_url(endpoint: Any) -> str:
|
||||
scheme = "https" if endpoint.tls else "http"
|
||||
port = endpoint.port
|
||||
host = endpoint.host
|
||||
if (scheme == "https" and port == 443) or (scheme == "http" and port == 80):
|
||||
return f"{scheme}://{host}/"
|
||||
return f"{scheme}://{host}:{port}/"
|
||||
|
||||
|
||||
async def _fetch_text(url: str, *, timeout_s: float) -> str:
|
||||
def _fetch() -> str:
|
||||
with urllib.request.urlopen(url, timeout=timeout_s) as response:
|
||||
payload = response.read()
|
||||
if isinstance(payload, bytes):
|
||||
return payload.decode("utf-8", errors="replace")
|
||||
return str(payload)
|
||||
|
||||
return await asyncio.to_thread(_fetch)
|
||||
|
||||
|
||||
async def _poll_http_preview(url: str, *, expected_substring: str, timeout_s: float) -> str:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
body = await _fetch_text(url, timeout_s=5.0)
|
||||
if expected_substring in body:
|
||||
return body
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
last_error = exc
|
||||
await asyncio.sleep(2)
|
||||
if last_error is not None:
|
||||
raise RuntimeError(f"HTTP preview never became ready: {last_error}") from last_error
|
||||
raise RuntimeError("HTTP preview never returned the expected content.")
|
||||
|
||||
|
||||
async def _preflight_public_resources(client: RunloopSandboxClient) -> dict[str, object]:
|
||||
blueprints = await _collect_async_items(
|
||||
await client.platform.blueprints.list_public(limit=3),
|
||||
limit=3,
|
||||
)
|
||||
benchmarks = await _collect_async_items(
|
||||
await client.platform.benchmarks.list_public(limit=3),
|
||||
limit=3,
|
||||
)
|
||||
|
||||
blueprint_summaries = [
|
||||
_summarize_resource(item, ("id", "name", "status")) for item in blueprints
|
||||
]
|
||||
benchmark_summaries = [
|
||||
_summarize_resource(item, ("id", "name", "description")) for item in benchmarks
|
||||
]
|
||||
|
||||
if blueprint_summaries:
|
||||
print("public blueprints:")
|
||||
for summary in blueprint_summaries:
|
||||
print(f" - {summary}")
|
||||
else:
|
||||
print("public blueprints: none returned")
|
||||
|
||||
if benchmark_summaries:
|
||||
print("public benchmarks:")
|
||||
for summary in benchmark_summaries:
|
||||
print(f" - {summary}")
|
||||
else:
|
||||
print("public benchmarks: none returned")
|
||||
|
||||
return {
|
||||
"public_blueprints": blueprint_summaries,
|
||||
"public_benchmarks": benchmark_summaries,
|
||||
}
|
||||
|
||||
|
||||
async def _query_runloop_secret(
|
||||
client: RunloopSandboxClient,
|
||||
*,
|
||||
name: str,
|
||||
) -> RunloopResourceQueryResult:
|
||||
try:
|
||||
secret = cast(Any, await client.platform.secrets.get(name))
|
||||
except Exception as exc:
|
||||
if _is_not_found(exc):
|
||||
return RunloopResourceQueryResult(resource_type="secret", name=name, found=False)
|
||||
raise
|
||||
|
||||
return RunloopResourceQueryResult(
|
||||
resource_type="secret",
|
||||
name=name,
|
||||
found=True,
|
||||
id=cast(str | None, getattr(secret, "id", None)),
|
||||
)
|
||||
|
||||
|
||||
async def _query_runloop_network_policy(
|
||||
client: RunloopSandboxClient,
|
||||
*,
|
||||
name: str,
|
||||
) -> RunloopResourceQueryResult:
|
||||
policies = await _collect_maybe_async_items(
|
||||
await client.platform.network_policies.list(name=name, limit=10),
|
||||
limit=10,
|
||||
)
|
||||
for policy in policies:
|
||||
if getattr(policy, "name", None) != name:
|
||||
continue
|
||||
info = cast(
|
||||
Any, await client.platform.network_policies.get(cast(str, policy.id)).get_info()
|
||||
)
|
||||
return RunloopResourceQueryResult(
|
||||
resource_type="network_policy",
|
||||
name=name,
|
||||
found=True,
|
||||
id=cast(str | None, getattr(policy, "id", None)),
|
||||
description=cast(str | None, getattr(info, "description", None)),
|
||||
)
|
||||
|
||||
return RunloopResourceQueryResult(resource_type="network_policy", name=name, found=False)
|
||||
|
||||
|
||||
def _build_resource_query_tools(
|
||||
client: RunloopSandboxClient,
|
||||
*,
|
||||
managed_secret_name: str,
|
||||
network_policy_name: str,
|
||||
) -> tuple[list[Any], dict[str, RunloopResourceQueryResult]]:
|
||||
query_results: dict[str, RunloopResourceQueryResult] = {}
|
||||
|
||||
@function_tool
|
||||
async def query_runloop_secret(name: str) -> RunloopResourceQueryResult:
|
||||
"""Query whether a Runloop secret exists by name and return non-sensitive metadata."""
|
||||
|
||||
result = await _query_runloop_secret(client, name=name)
|
||||
query_results["secret"] = result
|
||||
return result
|
||||
|
||||
@function_tool
|
||||
async def query_runloop_network_policy(name: str) -> RunloopResourceQueryResult:
|
||||
"""Query whether a Runloop network policy exists by name and return basic metadata."""
|
||||
|
||||
result = await _query_runloop_network_policy(client, name=name)
|
||||
query_results["network_policy"] = result
|
||||
return result
|
||||
|
||||
tools = [query_runloop_secret, query_runloop_network_policy]
|
||||
_ = (managed_secret_name, network_policy_name)
|
||||
return tools, query_results
|
||||
|
||||
|
||||
async def _run_resource_query_phase(
|
||||
client: RunloopSandboxClient,
|
||||
*,
|
||||
model: str,
|
||||
stream: bool,
|
||||
managed_secret_name: str,
|
||||
network_policy_name: str,
|
||||
) -> tuple[dict[str, RunloopResourceQueryResult], str]:
|
||||
tools, query_results = _build_resource_query_tools(
|
||||
client,
|
||||
managed_secret_name=managed_secret_name,
|
||||
network_policy_name=network_policy_name,
|
||||
)
|
||||
query_agent = Agent(
|
||||
name="Runloop Resource Discovery Guide",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Use both query tools before answering. You are checking whether the persistent "
|
||||
"Runloop example resources already exist before any create step.\n\n"
|
||||
f"1. Call `query_runloop_secret` with `{managed_secret_name}`.\n"
|
||||
f"2. Call `query_runloop_network_policy` with `{network_policy_name}`.\n"
|
||||
"3. Final answer in 2 short sentences stating whether each resource already exists."
|
||||
),
|
||||
tools=tools,
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
prompt = (
|
||||
"Check whether the persistent Runloop secret and network policy for this example already "
|
||||
"exist before the script attempts any create or reuse step."
|
||||
)
|
||||
output = await _run_plain_agent(
|
||||
agent=query_agent,
|
||||
prompt=prompt,
|
||||
workflow_name="Runloop resource query example",
|
||||
stream=stream,
|
||||
)
|
||||
if "secret" not in query_results or "network_policy" not in query_results:
|
||||
raise RuntimeError("The query agent did not call both Runloop resource query tools.")
|
||||
return query_results, output
|
||||
|
||||
|
||||
async def _bootstrap_persistent_resources(
|
||||
client: RunloopSandboxClient,
|
||||
*,
|
||||
managed_secret_name: str,
|
||||
managed_secret_value: str,
|
||||
network_policy_name: str,
|
||||
network_policy_id_override: str | None,
|
||||
query_results: dict[str, RunloopResourceQueryResult],
|
||||
axon_name: str | None,
|
||||
) -> dict[str, object]:
|
||||
secret_query = query_results["secret"]
|
||||
policy_query = query_results["network_policy"]
|
||||
|
||||
bootstrap: dict[str, object] = {
|
||||
"managed_secret_value": managed_secret_value,
|
||||
"secret": RunloopResourceBootstrapResult(
|
||||
resource_type="secret",
|
||||
name=managed_secret_name,
|
||||
action="reused" if secret_query.found else "created",
|
||||
id=secret_query.id,
|
||||
found_before_bootstrap=secret_query.found,
|
||||
),
|
||||
"network_policy": RunloopResourceBootstrapResult(
|
||||
resource_type="network_policy",
|
||||
name=network_policy_name,
|
||||
action="override"
|
||||
if network_policy_id_override
|
||||
else ("reused" if policy_query.found else "created"),
|
||||
id=network_policy_id_override or policy_query.id,
|
||||
found_before_bootstrap=policy_query.found,
|
||||
),
|
||||
"axon_id": None,
|
||||
"axon_name": axon_name,
|
||||
}
|
||||
|
||||
secret_result = cast(RunloopResourceBootstrapResult, bootstrap["secret"])
|
||||
if not secret_query.found:
|
||||
created_secret = cast(
|
||||
Any,
|
||||
await client.platform.secrets.create(
|
||||
name=managed_secret_name, value=managed_secret_value
|
||||
),
|
||||
)
|
||||
secret_result.id = cast(str | None, getattr(created_secret, "id", None))
|
||||
print(
|
||||
"persistent secret bootstrap:",
|
||||
secret_result.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
policy_result = cast(RunloopResourceBootstrapResult, bootstrap["network_policy"])
|
||||
if network_policy_id_override is None and not policy_query.found:
|
||||
try:
|
||||
created_policy = cast(
|
||||
Any,
|
||||
await client.platform.network_policies.create(
|
||||
name=network_policy_name,
|
||||
allow_all=True,
|
||||
description="Persistent network policy for the Runloop capabilities example.",
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
if not _is_conflict(exc):
|
||||
raise
|
||||
policy_result.action = "reused"
|
||||
policy_result.found_before_bootstrap = True
|
||||
refreshed_policy = await _query_runloop_network_policy(client, name=network_policy_name)
|
||||
policy_result.id = refreshed_policy.id
|
||||
else:
|
||||
policy_result.id = cast(str | None, getattr(created_policy, "id", None))
|
||||
print(
|
||||
"persistent network policy bootstrap:",
|
||||
policy_result.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
if axon_name is not None:
|
||||
axon = cast(Any, await client.platform.axons.create(name=axon_name))
|
||||
await client.platform.axons.query_sql(
|
||||
cast(str, axon.id),
|
||||
sql="CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL)",
|
||||
)
|
||||
await client.platform.axons.batch_sql(
|
||||
cast(str, axon.id),
|
||||
statements=[
|
||||
{"sql": "INSERT INTO events (kind) VALUES (?)", "params": ["capabilities"]},
|
||||
{"sql": "INSERT INTO events (kind) VALUES (?)", "params": ["agent_guided"]},
|
||||
],
|
||||
)
|
||||
query_result = cast(
|
||||
Any,
|
||||
await client.platform.axons.query_sql(
|
||||
cast(str, axon.id),
|
||||
sql="SELECT COUNT(*) AS total_events FROM events",
|
||||
),
|
||||
)
|
||||
publish_result = cast(
|
||||
Any,
|
||||
await client.platform.axons.publish(
|
||||
cast(str, axon.id),
|
||||
event_type="capabilities_example",
|
||||
origin="AGENT_EVENT",
|
||||
payload=json.dumps({"axon_name": axon_name}),
|
||||
source="openai-agents-python",
|
||||
),
|
||||
)
|
||||
bootstrap["axon_id"] = cast(str, axon.id)
|
||||
print(
|
||||
"axon demo created:",
|
||||
{
|
||||
"id": cast(str, axon.id),
|
||||
"name": axon_name,
|
||||
"rows": query_result.rows,
|
||||
"published": getattr(publish_result, "published", None),
|
||||
},
|
||||
)
|
||||
|
||||
return bootstrap
|
||||
|
||||
|
||||
def _optional_gateways(args: argparse.Namespace) -> dict[str, RunloopGatewaySpec]:
|
||||
if not (args.gateway_env_var and args.gateway_name and args.gateway_secret_name):
|
||||
return {}
|
||||
return {
|
||||
args.gateway_env_var: RunloopGatewaySpec(
|
||||
gateway=args.gateway_name,
|
||||
secret=args.gateway_secret_name,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _optional_mcp(args: argparse.Namespace) -> dict[str, RunloopMcpSpec]:
|
||||
if not (args.mcp_env_var and args.mcp_config and args.mcp_secret_name):
|
||||
return {}
|
||||
return {
|
||||
args.mcp_env_var: RunloopMcpSpec(
|
||||
mcp_config=args.mcp_config,
|
||||
secret=args.mcp_secret_name,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace) -> None:
|
||||
_require_env("OPENAI_API_KEY")
|
||||
_require_env("RUNLOOP_API_KEY")
|
||||
|
||||
workspace_root = (
|
||||
DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT if args.root else DEFAULT_RUNLOOP_WORKSPACE_ROOT
|
||||
)
|
||||
run_id = _run_id()
|
||||
metadata = {
|
||||
"example": "runloop-capabilities",
|
||||
"run_id": run_id,
|
||||
}
|
||||
|
||||
client = RunloopSandboxClient()
|
||||
session = None
|
||||
resumed = None
|
||||
session_closed = False
|
||||
resumed_closed = False
|
||||
|
||||
try:
|
||||
_phase("Public Resource Discovery")
|
||||
public_context = await _preflight_public_resources(client)
|
||||
|
||||
_phase("Agent Resource Discovery")
|
||||
query_results, query_agent_output = await _run_resource_query_phase(
|
||||
client,
|
||||
model=args.model,
|
||||
stream=args.stream,
|
||||
managed_secret_name=PERSISTENT_SECRET_NAME,
|
||||
network_policy_name=PERSISTENT_NETWORK_POLICY_NAME,
|
||||
)
|
||||
print(
|
||||
"resource query results:",
|
||||
{key: value.model_dump(mode="json") for key, value in query_results.items()},
|
||||
)
|
||||
|
||||
_phase("Persistent Resource Bootstrap")
|
||||
axon_name = f"{EXAMPLE_RESOURCE_SLUG}-axon-{run_id}" if args.with_axon_demo else None
|
||||
bootstrap = await _bootstrap_persistent_resources(
|
||||
client,
|
||||
managed_secret_name=PERSISTENT_SECRET_NAME,
|
||||
managed_secret_value=PERSISTENT_SECRET_VALUE,
|
||||
network_policy_name=PERSISTENT_NETWORK_POLICY_NAME,
|
||||
network_policy_id_override=args.network_policy_id,
|
||||
query_results=query_results,
|
||||
axon_name=axon_name,
|
||||
)
|
||||
secret_bootstrap = cast(RunloopResourceBootstrapResult, bootstrap["secret"])
|
||||
network_policy_bootstrap = cast(RunloopResourceBootstrapResult, bootstrap["network_policy"])
|
||||
network_policy_id = network_policy_bootstrap.id
|
||||
|
||||
context = {
|
||||
"example_slug": EXAMPLE_RESOURCE_SLUG,
|
||||
"workspace_root": workspace_root,
|
||||
"requested_blueprint_name": args.blueprint_name,
|
||||
"public_resources": public_context,
|
||||
"resource_query_agent_output": query_agent_output,
|
||||
"resource_queries": {
|
||||
key: value.model_dump(mode="json") for key, value in query_results.items()
|
||||
},
|
||||
"resource_bootstrap": {
|
||||
"secret": secret_bootstrap.model_dump(mode="json"),
|
||||
"network_policy": network_policy_bootstrap.model_dump(mode="json"),
|
||||
"axon_id": bootstrap["axon_id"],
|
||||
"axon_name": bootstrap["axon_name"],
|
||||
},
|
||||
"managed_secret_env_var": PERSISTENT_SECRET_NAME,
|
||||
"network_policy_id": network_policy_id,
|
||||
"metadata": metadata,
|
||||
"gateway_bindings": sorted(_optional_gateways(args)),
|
||||
"mcp_bindings": sorted(_optional_mcp(args)),
|
||||
}
|
||||
|
||||
manifest = _build_manifest(workspace_root=workspace_root, context=context)
|
||||
agent = _build_sandbox_agent(
|
||||
model=args.model,
|
||||
manifest=manifest,
|
||||
managed_secret_name=PERSISTENT_SECRET_NAME,
|
||||
)
|
||||
options = RunloopSandboxClientOptions(
|
||||
blueprint_name=args.blueprint_name,
|
||||
pause_on_exit=True,
|
||||
exposed_ports=(args.http_port,),
|
||||
user_parameters=(RunloopUserParameters(username="root", uid=0) if args.root else None),
|
||||
launch_parameters=RunloopLaunchParameters(
|
||||
network_policy_id=network_policy_id,
|
||||
resource_size_request=args.resource_size,
|
||||
after_idle=RunloopAfterIdle(idle_time_seconds=300, on_idle="suspend"),
|
||||
launch_commands=["echo runloop-capabilities-example"],
|
||||
),
|
||||
tunnel=RunloopTunnelConfig(
|
||||
auth_mode="open",
|
||||
http_keep_alive=True,
|
||||
wake_on_http=True,
|
||||
),
|
||||
gateways=_optional_gateways(args),
|
||||
mcp=_optional_mcp(args),
|
||||
metadata=metadata,
|
||||
managed_secrets={PERSISTENT_SECRET_NAME: PERSISTENT_SECRET_VALUE},
|
||||
)
|
||||
|
||||
_phase("Sandbox Create")
|
||||
session = await client.create(manifest=manifest, options=options)
|
||||
await session.start()
|
||||
session_state = _runloop_state(session)
|
||||
print(
|
||||
"session started:",
|
||||
{
|
||||
"devbox_id": session_state.devbox_id,
|
||||
"secret_refs": session_state.secret_refs,
|
||||
"metadata": session_state.metadata,
|
||||
},
|
||||
)
|
||||
|
||||
_phase("Tunnel Check")
|
||||
await _write_json(
|
||||
session,
|
||||
RUNTIME_CONTEXT_PATH,
|
||||
{
|
||||
**context,
|
||||
"devbox_id": session_state.devbox_id,
|
||||
"secret_refs": session_state.secret_refs,
|
||||
"runtime_phase": "before_tunnel_check",
|
||||
},
|
||||
)
|
||||
await _start_http_server(session, port=args.http_port, workspace_root=workspace_root)
|
||||
endpoint = await session.resolve_exposed_port(args.http_port)
|
||||
preview_url = urljoin(_build_endpoint_url(endpoint), "README.md")
|
||||
preview_body = await _poll_http_preview(
|
||||
preview_url,
|
||||
expected_substring="Runloop Capabilities Example",
|
||||
timeout_s=45.0,
|
||||
)
|
||||
print("resolved tunnel:", preview_url)
|
||||
await _write_json(
|
||||
session,
|
||||
RUNTIME_CONTEXT_PATH,
|
||||
{
|
||||
**context,
|
||||
"devbox_id": session_state.devbox_id,
|
||||
"secret_refs": session_state.secret_refs,
|
||||
"tunnel_url": preview_url,
|
||||
"http_preview_contains_readme": "Runloop Capabilities Example" in preview_body,
|
||||
"runtime_phase": "before_agent_run",
|
||||
},
|
||||
)
|
||||
|
||||
_phase("Agent Verification")
|
||||
await _run_sandbox_agent(
|
||||
agent=agent,
|
||||
prompt=args.prompt,
|
||||
session=session,
|
||||
workflow_name="Runloop capabilities example",
|
||||
stream=args.stream,
|
||||
)
|
||||
proof_text = await _read_text(session, AGENT_PROOF_PATH)
|
||||
print("agent proof:")
|
||||
print(proof_text.rstrip())
|
||||
|
||||
_phase("Suspend")
|
||||
await session.aclose()
|
||||
session_closed = True
|
||||
print("session persisted and suspended")
|
||||
|
||||
_phase("Resume Check")
|
||||
resumed = await client.resume(session.state)
|
||||
await resumed.start()
|
||||
resumed_state = _runloop_state(resumed)
|
||||
resumed_runtime_context = await _read_text(resumed, RUNTIME_CONTEXT_PATH)
|
||||
resumed_proof_text = await _read_text(resumed, AGENT_PROOF_PATH)
|
||||
print("resumed runtime context bytes:", len(resumed_runtime_context.encode("utf-8")))
|
||||
print("resumed proof:")
|
||||
print(resumed_proof_text.rstrip())
|
||||
resumed_state.pause_on_exit = False
|
||||
await resumed.aclose()
|
||||
resumed_closed = True
|
||||
print("resumed session cleaned up with delete semantics")
|
||||
|
||||
_phase("Persistent Resource Summary")
|
||||
print(
|
||||
"persistent resources retained:",
|
||||
{
|
||||
"secret": secret_bootstrap.model_dump(mode="json"),
|
||||
"network_policy": network_policy_bootstrap.model_dump(mode="json"),
|
||||
},
|
||||
)
|
||||
if bootstrap["axon_id"] is not None:
|
||||
print(
|
||||
"axon retained for manual cleanup:",
|
||||
{
|
||||
"axon_id": bootstrap["axon_id"],
|
||||
"axon_name": bootstrap["axon_name"],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if resumed is not None and not resumed_closed:
|
||||
try:
|
||||
_runloop_state(resumed).pause_on_exit = False
|
||||
await resumed.aclose()
|
||||
except Exception as exc:
|
||||
print(f"warning: failed to close resumed session cleanly: {exc}")
|
||||
elif session is not None and not session_closed:
|
||||
try:
|
||||
_runloop_state(session).pause_on_exit = False
|
||||
await session.aclose()
|
||||
except Exception as exc:
|
||||
print(f"warning: failed to close initial session cleanly: {exc}")
|
||||
elif session is not None and session_closed and resumed is None:
|
||||
try:
|
||||
cleanup_session = await client.resume(session.state)
|
||||
_runloop_state(cleanup_session).pause_on_exit = False
|
||||
await cleanup_session.aclose()
|
||||
except Exception as exc:
|
||||
print(f"warning: failed to resume suspended session for cleanup: {exc}")
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
|
||||
parser.add_argument(
|
||||
"--prompt", default=DEFAULT_AGENT_PROMPT, help="Prompt to send to the agent."
|
||||
)
|
||||
parser.add_argument("--blueprint-name", default=None, help="Optional Runloop blueprint name.")
|
||||
parser.add_argument(
|
||||
"--resource-size",
|
||||
default="MEDIUM",
|
||||
choices=["X_SMALL", "SMALL", "MEDIUM", "LARGE", "X_LARGE", "XX_LARGE", "CUSTOM_SIZE"],
|
||||
help="Runloop resource size request for the devbox.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--network-policy-id",
|
||||
default=None,
|
||||
help="Optional Runloop network policy id override. Without this flag, the example reuses or creates the persistent example policy by name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--http-port",
|
||||
type=int,
|
||||
default=DEFAULT_HTTP_PORT,
|
||||
help="Port used by the preview HTTP server.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the Runloop devbox as root. The workspace root becomes /root.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stream",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Stream the agent response and tool activity.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--with-axon-demo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Also create and use a temporary Axon. This leaves the Axon behind for manual cleanup.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gateway-env-var", default=None, help="Env var name for a gateway binding."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gateway-name", default=None, help="Runloop gateway name for the binding."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gateway-secret-name",
|
||||
default=None,
|
||||
help="Runloop secret name used by the gateway binding.",
|
||||
)
|
||||
parser.add_argument("--mcp-env-var", default=None, help="Env var name for an MCP binding.")
|
||||
parser.add_argument(
|
||||
"--mcp-config", default=None, help="Runloop MCP config name for the binding."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mcp-secret-name",
|
||||
default=None,
|
||||
help="Runloop secret name used by the MCP binding.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(_build_parser().parse_args()))
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Minimal Runloop-backed sandbox example for manual validation.
|
||||
|
||||
This mirrors the other cloud extension examples: it creates a tiny workspace, asks a sandboxed
|
||||
agent to inspect it through one shell tool, and prints a short answer.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import (
|
||||
DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT,
|
||||
DEFAULT_RUNLOOP_WORKSPACE_ROOT,
|
||||
RunloopSandboxClient,
|
||||
RunloopSandboxClientOptions,
|
||||
RunloopUserParameters,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
||||
raise SystemExit(
|
||||
"Runloop sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra runloop"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
|
||||
|
||||
|
||||
def _build_manifest(*, workspace_root: str) -> Manifest:
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Runloop Demo Workspace\n\n"
|
||||
"This workspace exists to validate the Runloop sandbox backend manually.\n"
|
||||
),
|
||||
"launch.md": (
|
||||
"# Launch\n\n"
|
||||
"- Customer: Contoso Logistics.\n"
|
||||
"- Goal: validate the remote sandbox agent path.\n"
|
||||
"- Current status: Runloop backend smoke and app-server connectivity are passing.\n"
|
||||
),
|
||||
"tasks.md": (
|
||||
"# Tasks\n\n"
|
||||
"1. Inspect the workspace files.\n"
|
||||
"2. Summarize the setup and any notable status in two sentences.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
return Manifest(root=workspace_root, entries=manifest.entries)
|
||||
|
||||
|
||||
def _require_env(name: str) -> None:
|
||||
if os.environ.get(name):
|
||||
return
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
async def main(
|
||||
*,
|
||||
model: str,
|
||||
question: str,
|
||||
pause_on_exit: bool,
|
||||
blueprint_name: str | None,
|
||||
root: bool,
|
||||
stream: bool,
|
||||
) -> None:
|
||||
_require_env("OPENAI_API_KEY")
|
||||
_require_env("RUNLOOP_API_KEY")
|
||||
|
||||
workspace_root = DEFAULT_RUNLOOP_ROOT_WORKSPACE_ROOT if root else DEFAULT_RUNLOOP_WORKSPACE_ROOT
|
||||
manifest = _build_manifest(workspace_root=workspace_root)
|
||||
agent = SandboxAgent(
|
||||
name="Runloop Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the files before answering "
|
||||
"and keep the response concise. "
|
||||
"Do not invent files or statuses that are not present in the workspace. Cite the "
|
||||
"file names you inspected."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
client = RunloopSandboxClient()
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=client,
|
||||
options=RunloopSandboxClientOptions(
|
||||
blueprint_name=blueprint_name,
|
||||
pause_on_exit=pause_on_exit,
|
||||
user_parameters=(RunloopUserParameters(username="root", uid=0) if root else None),
|
||||
),
|
||||
),
|
||||
workflow_name="Runloop sandbox example",
|
||||
)
|
||||
|
||||
try:
|
||||
if not stream:
|
||||
result = await Runner.run(agent, question, run_config=run_config)
|
||||
print(result.final_output)
|
||||
return
|
||||
|
||||
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
|
||||
saw_text_delta = False
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
parser.add_argument(
|
||||
"--pause-on-exit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Suspend the Runloop devbox on shutdown instead of deleting it.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--blueprint-name",
|
||||
default=None,
|
||||
help="Optional Runloop blueprint name to use when creating the devbox.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the Runloop devbox as root. The default home/workspace root becomes /root.",
|
||||
)
|
||||
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
model=args.model,
|
||||
question=args.question,
|
||||
pause_on_exit=args.pause_on_exit,
|
||||
blueprint_name=args.blueprint_name,
|
||||
root=args.root,
|
||||
stream=args.stream,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
# Temporal Sandbox Agent
|
||||
|
||||
A conversational coding agent that runs as a durable Temporal workflow with
|
||||
support for multiple sandbox backends (Daytona, Docker, E2B, local unix).
|
||||
|
||||
## Quickstart
|
||||
|
||||
**Prerequisites:** Docker (for the Docker backend) and API keys for any
|
||||
cloud backends you want to use. The local and Docker sandboxes work without
|
||||
any cloud provider API keys.
|
||||
|
||||
1. Install [just](https://just.systems/man/en/packages.html) and the
|
||||
[Temporal CLI](https://docs.temporal.io/cli/setup-cli#install-the-cli)
|
||||
if you don't have them already.
|
||||
|
||||
2. Change into the example directory:
|
||||
|
||||
```
|
||||
cd examples/sandbox/extensions/temporal
|
||||
```
|
||||
|
||||
3. Create a `.env` file in this directory with your API keys:
|
||||
|
||||
```
|
||||
OPENAI_API_KEY="sk-..."
|
||||
DAYTONA_API_KEY="dtn_..." # optional, for Daytona backend
|
||||
E2B_API_KEY="e2b_..." # optional, for E2B backend
|
||||
```
|
||||
|
||||
4. Start the Temporal dev server:
|
||||
|
||||
```
|
||||
just temporal
|
||||
```
|
||||
|
||||
5. In a second terminal, start the worker:
|
||||
|
||||
```
|
||||
just worker
|
||||
```
|
||||
|
||||
6. In a third terminal, start the TUI:
|
||||
|
||||
```
|
||||
just tui
|
||||
```
|
||||
|
||||
The `just worker` and `just tui` commands automatically install dependencies
|
||||
and patch the installed `temporalio` package with vendored sandbox support.
|
||||
This patch step is temporary -- the next `temporalio` release will include
|
||||
sandbox support natively, at which point the vendored plugin and patch step
|
||||
will be removed. Until then, running the Python scripts directly without
|
||||
the patch step (i.e. skipping `just worker`/`just tui`) will fail at import
|
||||
time.
|
||||
|
||||
## TUI commands
|
||||
|
||||
| Command | Description |
|
||||
|--------------------|--------------------------------------------------------|
|
||||
| `/switch` | Switch the current session to a different sandbox backend |
|
||||
| `/fork [title]` | Fork the session onto a (possibly different) backend |
|
||||
| `/title <name>` | Rename the current session |
|
||||
| `/done` | Exit the TUI |
|
||||
|
||||
Both `/switch` and `/fork` open an interactive backend picker. When switching
|
||||
to the local backend you can specify the workspace root directory.
|
||||
|
||||
## How it works
|
||||
|
||||
A single Temporal worker registers all sandbox backends via
|
||||
`SandboxClientProvider`, so every backend's activities are available on one
|
||||
task queue. The workflow picks which backend to target each turn by calling
|
||||
`temporal_sandbox_client(name)` in its `RunConfig`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- `temporal_sandbox_agent.py` -- The `AgentWorkflow` definition and worker
|
||||
entrypoint. Each conversation turn calls `Runner.run()` with a
|
||||
`SandboxRunConfig` that targets the active backend. The workflow is
|
||||
long-lived: it idles between turns and persists indefinitely in Temporal.
|
||||
- `temporal_session_manager.py` -- A singleton `SessionManagerWorkflow` that
|
||||
tracks active sessions and handles create, fork, switch, and destroy
|
||||
operations.
|
||||
- `temporal_sandbox_tui.py` -- A [Textual](https://textual.textualize.io/) TUI
|
||||
that connects to the session manager and drives conversations via signals,
|
||||
updates, and queries.
|
||||
- `examples/sandbox/misc/workspace_shell.py` -- A shared `Capability` that
|
||||
gives the agent a shell tool for running commands in the sandbox workspace.
|
||||
|
||||
**Switching backends** is an in-place operation: the workflow receives a
|
||||
`switch_backend` update, changes its backend and manifest, clears the
|
||||
backend-specific session state, and the next turn creates a fresh session on
|
||||
the new backend. The portable snapshot is preserved so workspace files carry
|
||||
over.
|
||||
|
||||
**Forking** pauses the source workflow, snapshots its state and conversation
|
||||
history, and starts a new child workflow on the chosen backend. The fork gets
|
||||
an independent copy of the workspace and conversation.
|
||||
@@ -0,0 +1,35 @@
|
||||
# vendored pre-release code; type errors are misreported due to patching
|
||||
# mypy: ignore-errors
|
||||
"""Support for using the OpenAI Agents SDK as part of Temporal workflows.
|
||||
|
||||
This module provides compatibility between the
|
||||
`OpenAI Agents SDK <https://github.com/openai/openai-agents-python>`_ and Temporal workflows.
|
||||
"""
|
||||
|
||||
from temporalio.contrib.openai_agents._mcp import (
|
||||
StatefulMCPServerProvider,
|
||||
StatelessMCPServerProvider,
|
||||
)
|
||||
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
|
||||
from temporalio.contrib.openai_agents._temporal_openai_agents import (
|
||||
OpenAIAgentsPlugin,
|
||||
OpenAIPayloadConverter,
|
||||
)
|
||||
from temporalio.contrib.openai_agents.sandbox._sandbox_client_provider import (
|
||||
SandboxClientProvider,
|
||||
)
|
||||
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
|
||||
|
||||
from . import testing, workflow
|
||||
|
||||
__all__ = [
|
||||
"AgentsWorkflowError",
|
||||
"ModelActivityParameters",
|
||||
"OpenAIAgentsPlugin",
|
||||
"OpenAIPayloadConverter",
|
||||
"SandboxClientProvider",
|
||||
"StatelessMCPServerProvider",
|
||||
"StatefulMCPServerProvider",
|
||||
"testing",
|
||||
"workflow",
|
||||
]
|
||||
@@ -0,0 +1,301 @@
|
||||
# vendored pre-release code; type errors are misreported due to patching
|
||||
# mypy: ignore-errors
|
||||
"""A temporal activity that invokes a LLM model.
|
||||
|
||||
Implements mapping of OpenAI datastructures to Pydantic friendly types.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from openai import (
|
||||
APIStatusError,
|
||||
AsyncOpenAI,
|
||||
)
|
||||
from openai.types.responses.tool_param import Mcp
|
||||
from temporalio import activity
|
||||
from temporalio.contrib.openai_agents._heartbeat_decorator import _auto_heartbeater
|
||||
from temporalio.exceptions import ApplicationError
|
||||
from typing_extensions import Required, TypedDict
|
||||
|
||||
from agents import (
|
||||
AgentOutputSchemaBase,
|
||||
CodeInterpreterTool,
|
||||
FileSearchTool,
|
||||
FunctionTool,
|
||||
Handoff,
|
||||
HostedMCPTool,
|
||||
ImageGenerationTool,
|
||||
ModelProvider,
|
||||
ModelResponse,
|
||||
ModelSettings,
|
||||
ModelTracing,
|
||||
OpenAIProvider,
|
||||
RunContextWrapper,
|
||||
Tool,
|
||||
TResponseInputItem,
|
||||
UserError,
|
||||
WebSearchTool,
|
||||
)
|
||||
from agents.tool import ApplyPatchTool, LocalShellTool, ShellTool, ToolSearchTool
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandoffInput:
|
||||
"""Data conversion friendly representation of a Handoff. Contains only the fields which are needed by the model
|
||||
execution to determine what to handoff to, not the actual handoff invocation, which remains in the workflow context.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
tool_description: str
|
||||
input_json_schema: dict[str, Any]
|
||||
agent_name: str
|
||||
strict_json_schema: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionToolInput:
|
||||
"""Data conversion friendly representation of a FunctionTool. Contains only the fields which are needed by the model
|
||||
execution to determine what tool to call, not the actual tool invocation, which remains in the workflow context.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
params_json_schema: dict[str, Any]
|
||||
strict_json_schema: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostedMCPToolInput:
|
||||
"""Data conversion friendly representation of a HostedMCPTool. Contains only the fields which are needed by the model
|
||||
execution to determine what tool to call, not the actual tool invocation, which remains in the workflow context.
|
||||
"""
|
||||
|
||||
tool_config: Mcp
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShellToolInput:
|
||||
"""Data conversion friendly representation of a ShellTool. Contains only the fields which are needed by the model
|
||||
execution to determine what tool to call, not the actual tool invocation, which remains in the workflow context.
|
||||
"""
|
||||
|
||||
name: str = "shell"
|
||||
environment: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyPatchToolInput:
|
||||
"""Data conversion friendly representation of an ApplyPatchTool."""
|
||||
|
||||
name: str = "apply_patch"
|
||||
|
||||
|
||||
ToolInput = (
|
||||
FunctionToolInput
|
||||
| FileSearchTool
|
||||
| WebSearchTool
|
||||
| ImageGenerationTool
|
||||
| CodeInterpreterTool
|
||||
| HostedMCPToolInput
|
||||
| ShellToolInput
|
||||
| LocalShellTool
|
||||
| ApplyPatchToolInput
|
||||
| ToolSearchTool
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentOutputSchemaInput(AgentOutputSchemaBase):
|
||||
"""Data conversion friendly representation of AgentOutputSchema."""
|
||||
|
||||
output_type_name: str | None
|
||||
is_wrapped: bool
|
||||
output_schema: dict[str, Any] | None
|
||||
strict_json_schema: bool
|
||||
|
||||
def is_plain_text(self) -> bool:
|
||||
"""Whether the output type is plain text (versus a JSON object)."""
|
||||
return self.output_type_name is None or self.output_type_name == "str"
|
||||
|
||||
def is_strict_json_schema(self) -> bool:
|
||||
"""Whether the JSON schema is in strict mode."""
|
||||
return self.strict_json_schema
|
||||
|
||||
def json_schema(self) -> dict[str, Any]:
|
||||
"""The JSON schema of the output type."""
|
||||
if self.is_plain_text():
|
||||
raise UserError("Output type is plain text, so no JSON schema is available")
|
||||
if self.output_schema is None:
|
||||
raise UserError("Output schema is not defined")
|
||||
return self.output_schema
|
||||
|
||||
def validate_json(self, json_str: str) -> Any:
|
||||
"""Validate the JSON string against the schema."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def name(self) -> str:
|
||||
"""Get the name of the output type."""
|
||||
if self.output_type_name is None:
|
||||
raise ValueError("output_type_name is None")
|
||||
return self.output_type_name
|
||||
|
||||
|
||||
class ModelTracingInput(enum.IntEnum):
|
||||
"""Conversion friendly representation of ModelTracing.
|
||||
|
||||
Needed as ModelTracing is enum.Enum instead of IntEnum
|
||||
"""
|
||||
|
||||
DISABLED = 0
|
||||
ENABLED = 1
|
||||
ENABLED_WITHOUT_DATA = 2
|
||||
|
||||
|
||||
class ActivityModelInput(TypedDict, total=False):
|
||||
"""Input for the invoke_model_activity activity."""
|
||||
|
||||
model_name: str | None
|
||||
system_instructions: str | None
|
||||
input: Required[str | list[TResponseInputItem]]
|
||||
model_settings: Required[ModelSettings]
|
||||
tools: list[ToolInput]
|
||||
output_schema: AgentOutputSchemaInput | None
|
||||
handoffs: list[HandoffInput]
|
||||
tracing: Required[ModelTracingInput]
|
||||
previous_response_id: str | None
|
||||
conversation_id: str | None
|
||||
prompt: Any | None
|
||||
|
||||
|
||||
class ModelActivity:
|
||||
"""Class wrapper for model invocation activities to allow model customization. By default, we use an OpenAIProvider with retries disabled.
|
||||
Disabling retries in your model of choice is recommended to allow activity retries to define the retry model.
|
||||
"""
|
||||
|
||||
def __init__(self, model_provider: ModelProvider | None = None):
|
||||
"""Initialize the activity with a model provider."""
|
||||
self._model_provider = model_provider or OpenAIProvider(
|
||||
openai_client=AsyncOpenAI(max_retries=0)
|
||||
)
|
||||
|
||||
@activity.defn
|
||||
@_auto_heartbeater
|
||||
async def invoke_model_activity(self, input: ActivityModelInput) -> ModelResponse:
|
||||
"""Activity that invokes a model with the given input."""
|
||||
model = self._model_provider.get_model(input.get("model_name"))
|
||||
|
||||
async def empty_on_invoke_tool(_ctx: RunContextWrapper[Any], _input: str) -> str:
|
||||
return ""
|
||||
|
||||
async def empty_on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Any:
|
||||
return None
|
||||
|
||||
def make_tool(tool: ToolInput) -> Tool:
|
||||
if isinstance(
|
||||
tool,
|
||||
FileSearchTool
|
||||
| WebSearchTool
|
||||
| ImageGenerationTool
|
||||
| CodeInterpreterTool
|
||||
| LocalShellTool
|
||||
| ToolSearchTool,
|
||||
):
|
||||
return tool
|
||||
elif isinstance(tool, ShellToolInput):
|
||||
|
||||
async def _noop_executor(*a: Any, **kw: Any) -> str:
|
||||
return ""
|
||||
|
||||
return ShellTool(
|
||||
name=tool.name,
|
||||
environment=tool.environment, # type: ignore[arg-type]
|
||||
executor=_noop_executor,
|
||||
)
|
||||
elif isinstance(tool, ApplyPatchToolInput):
|
||||
# Reconstruct with a no-op editor for the model call
|
||||
async def _noop_editor(*a: Any, **kw: Any) -> str:
|
||||
return ""
|
||||
|
||||
return ApplyPatchTool(
|
||||
name=tool.name,
|
||||
editor=_noop_editor, # type: ignore[arg-type]
|
||||
)
|
||||
elif isinstance(tool, HostedMCPToolInput):
|
||||
return HostedMCPTool(
|
||||
tool_config=tool.tool_config,
|
||||
)
|
||||
elif isinstance(tool, FunctionToolInput):
|
||||
return FunctionTool(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
params_json_schema=tool.params_json_schema,
|
||||
on_invoke_tool=empty_on_invoke_tool,
|
||||
strict_json_schema=tool.strict_json_schema,
|
||||
)
|
||||
else:
|
||||
raise UserError(f"Unknown tool type: {tool.name}") # type:ignore[reportUnreachable]
|
||||
|
||||
tools = [make_tool(x) for x in input.get("tools", [])]
|
||||
handoffs: list[Handoff[Any, Any]] = [
|
||||
Handoff(
|
||||
tool_name=x.tool_name,
|
||||
tool_description=x.tool_description,
|
||||
input_json_schema=x.input_json_schema,
|
||||
agent_name=x.agent_name,
|
||||
strict_json_schema=x.strict_json_schema,
|
||||
on_invoke_handoff=empty_on_invoke_handoff,
|
||||
)
|
||||
for x in input.get("handoffs", [])
|
||||
]
|
||||
|
||||
try:
|
||||
return await model.get_response(
|
||||
system_instructions=input.get("system_instructions"),
|
||||
input=input["input"],
|
||||
model_settings=input["model_settings"],
|
||||
tools=tools,
|
||||
output_schema=input.get("output_schema"),
|
||||
handoffs=handoffs,
|
||||
tracing=ModelTracing(input["tracing"]),
|
||||
previous_response_id=input.get("previous_response_id"),
|
||||
conversation_id=input.get("conversation_id"),
|
||||
prompt=input.get("prompt"),
|
||||
)
|
||||
except APIStatusError as e:
|
||||
# Listen to server hints
|
||||
retry_after = None
|
||||
retry_after_ms_header = e.response.headers.get("retry-after-ms")
|
||||
if retry_after_ms_header is not None:
|
||||
retry_after = timedelta(milliseconds=float(retry_after_ms_header))
|
||||
|
||||
if retry_after is None:
|
||||
retry_after_header = e.response.headers.get("retry-after")
|
||||
if retry_after_header is not None:
|
||||
retry_after = timedelta(seconds=float(retry_after_header))
|
||||
|
||||
should_retry_header = e.response.headers.get("x-should-retry")
|
||||
if should_retry_header == "true":
|
||||
raise e
|
||||
if should_retry_header == "false":
|
||||
raise ApplicationError(
|
||||
"Non retryable OpenAI error",
|
||||
non_retryable=True,
|
||||
next_retry_delay=retry_after,
|
||||
) from e
|
||||
|
||||
# Specifically retryable status codes
|
||||
if e.response.status_code in [408, 409, 429] or e.response.status_code >= 500:
|
||||
raise ApplicationError(
|
||||
f"Retryable OpenAI status code: {e.response.status_code}",
|
||||
non_retryable=False,
|
||||
next_retry_delay=retry_after,
|
||||
) from e
|
||||
|
||||
raise ApplicationError(
|
||||
f"Non retryable OpenAI status code: {e.response.status_code}",
|
||||
non_retryable=True,
|
||||
next_retry_delay=retry_after,
|
||||
) from e
|
||||
@@ -0,0 +1,254 @@
|
||||
# vendored pre-release code; type errors are misreported due to patching
|
||||
# mypy: ignore-errors
|
||||
import dataclasses
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Unpack
|
||||
|
||||
from temporalio import workflow
|
||||
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
|
||||
from temporalio.contrib.openai_agents._temporal_model_stub import _TemporalModelStub
|
||||
from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_client import (
|
||||
TemporalSandboxClient,
|
||||
)
|
||||
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
|
||||
|
||||
from agents import (
|
||||
Agent,
|
||||
AgentsException,
|
||||
Handoff,
|
||||
RunConfig,
|
||||
RunContextWrapper,
|
||||
RunResult,
|
||||
RunResultStreaming,
|
||||
RunState,
|
||||
SQLiteSession,
|
||||
TContext,
|
||||
TResponseInputItem,
|
||||
)
|
||||
from agents.run import DEFAULT_AGENT_RUNNER, DEFAULT_MAX_TURNS, AgentRunner, RunOptions
|
||||
from agents.sandbox import SandboxAgent
|
||||
|
||||
|
||||
# Recursively replace models in all agents
|
||||
def _convert_agent(
|
||||
model_params: ModelActivityParameters,
|
||||
agent: Agent[Any],
|
||||
seen: dict[int, Agent] | None,
|
||||
) -> Agent[Any]:
|
||||
if seen is None:
|
||||
seen = {}
|
||||
|
||||
# Short circuit if this model was already seen to prevent looping from circular handoffs
|
||||
if id(agent) in seen:
|
||||
return seen[id(agent)]
|
||||
|
||||
# This agent has already been processed in some other run
|
||||
if isinstance(agent.model, _TemporalModelStub):
|
||||
return agent
|
||||
|
||||
# Save the new version of the agent so that we can replace loops
|
||||
new_agent = dataclasses.replace(agent)
|
||||
seen[id(agent)] = new_agent
|
||||
|
||||
name = _model_name(agent)
|
||||
|
||||
new_handoffs: list[Agent | Handoff] = []
|
||||
for handoff in agent.handoffs:
|
||||
if isinstance(handoff, Agent):
|
||||
new_handoffs.append(_convert_agent(model_params, handoff, seen))
|
||||
elif isinstance(handoff, Handoff):
|
||||
original_invoke = handoff.on_invoke_handoff
|
||||
|
||||
# Use default parameter to capture original_invoke by value, not reference
|
||||
async def on_invoke(
|
||||
context: RunContextWrapper[Any],
|
||||
args: str,
|
||||
invoke_func: Callable[
|
||||
[RunContextWrapper[Any], str], Awaitable[Any]
|
||||
] = original_invoke,
|
||||
) -> Agent:
|
||||
handoff_agent = await invoke_func(context, args)
|
||||
return _convert_agent(model_params, handoff_agent, seen)
|
||||
|
||||
new_handoffs.append(dataclasses.replace(handoff, on_invoke_handoff=on_invoke))
|
||||
else:
|
||||
raise TypeError(f"Unknown handoff type: {type(handoff)}")
|
||||
|
||||
new_agent.model = _TemporalModelStub(
|
||||
model_name=name,
|
||||
model_params=model_params,
|
||||
agent=agent,
|
||||
)
|
||||
new_agent.handoffs = new_handoffs
|
||||
return new_agent
|
||||
|
||||
|
||||
def _has_sandbox_agent(agent: Agent[Any], seen: set[int] | None = None) -> bool:
|
||||
"""Check if any agent in the graph (following direct Agent handoffs) is a SandboxAgent."""
|
||||
if seen is None:
|
||||
seen = set()
|
||||
if id(agent) in seen:
|
||||
return False
|
||||
seen.add(id(agent))
|
||||
if isinstance(agent, SandboxAgent):
|
||||
return True
|
||||
for handoff in agent.handoffs:
|
||||
if isinstance(handoff, Agent) and _has_sandbox_agent(handoff, seen):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TemporalOpenAIRunner(AgentRunner):
|
||||
"""Temporal Runner for OpenAI agents.
|
||||
|
||||
Forwards model calls to a Temporal activity.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_params: ModelActivityParameters,
|
||||
) -> None:
|
||||
"""Initialize the Temporal OpenAI Runner."""
|
||||
self._runner = DEFAULT_AGENT_RUNNER or AgentRunner()
|
||||
self.model_params = model_params
|
||||
|
||||
async def run(
|
||||
self,
|
||||
starting_agent: Agent[TContext],
|
||||
input: str | list[TResponseInputItem] | RunState[TContext],
|
||||
**kwargs: Unpack[RunOptions[TContext]],
|
||||
) -> RunResult:
|
||||
"""Run the agent in a Temporal workflow."""
|
||||
if not workflow.in_workflow():
|
||||
return await self._runner.run(
|
||||
starting_agent,
|
||||
input,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
for t in starting_agent.tools:
|
||||
if callable(t):
|
||||
raise ValueError(
|
||||
"Provided tool is not a tool type. If using an activity, make sure to wrap it with openai_agents.workflow.activity_as_tool."
|
||||
)
|
||||
|
||||
if starting_agent.mcp_servers:
|
||||
from temporalio.contrib.openai_agents._mcp import (
|
||||
_StatefulMCPServerReference,
|
||||
_StatelessMCPServerReference,
|
||||
)
|
||||
|
||||
for s in starting_agent.mcp_servers:
|
||||
if not isinstance(
|
||||
s,
|
||||
_StatelessMCPServerReference | _StatefulMCPServerReference,
|
||||
):
|
||||
raise ValueError(f"Unknown mcp_server type {type(s)} may not work durably.")
|
||||
|
||||
context = kwargs.get("context")
|
||||
max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS)
|
||||
hooks = kwargs.get("hooks")
|
||||
run_config = kwargs.get("run_config")
|
||||
previous_response_id = kwargs.get("previous_response_id")
|
||||
session = kwargs.get("session")
|
||||
|
||||
if isinstance(session, SQLiteSession):
|
||||
raise ValueError("Temporal workflows don't support SQLite sessions.")
|
||||
|
||||
if run_config is None:
|
||||
run_config = RunConfig()
|
||||
|
||||
if run_config.model and not isinstance(run_config.model, _TemporalModelStub):
|
||||
if not isinstance(run_config.model, str):
|
||||
raise ValueError(
|
||||
"Temporal workflows require a model name to be a string in the run config."
|
||||
)
|
||||
run_config = dataclasses.replace(
|
||||
run_config,
|
||||
model=_TemporalModelStub(
|
||||
run_config.model, model_params=self.model_params, agent=None
|
||||
),
|
||||
)
|
||||
# run_config.sandbox is global for the entire run — configure it if any agent needs it.
|
||||
if _has_sandbox_agent(starting_agent) or run_config.sandbox:
|
||||
if run_config.sandbox is None:
|
||||
raise ValueError(
|
||||
"A SandboxAgent was provided but run_config.sandbox is not configured. "
|
||||
"You must set run_config.sandbox to a SandboxRunConfig. "
|
||||
"For example:\n"
|
||||
" from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client\n"
|
||||
" run_config = RunConfig(sandbox=SandboxRunConfig(client=temporal_sandbox_client('my-backend')))"
|
||||
)
|
||||
elif run_config.sandbox.client is None:
|
||||
raise ValueError(
|
||||
"run_config.sandbox.client must be set to a temporal sandbox client. "
|
||||
"Use temporalio.contrib.openai_agents.workflow.temporal_sandbox_client(name) "
|
||||
"to create one, where name matches a SandboxClientProvider registered on the plugin."
|
||||
)
|
||||
elif not isinstance(run_config.sandbox.client, TemporalSandboxClient):
|
||||
raise ValueError(
|
||||
"run_config.sandbox.client must be created via "
|
||||
"temporalio.contrib.openai_agents.workflow.temporal_sandbox_client(name). "
|
||||
"Do not pass a raw sandbox client directly."
|
||||
)
|
||||
|
||||
try:
|
||||
return await self._runner.run(
|
||||
starting_agent=_convert_agent(self.model_params, starting_agent, None),
|
||||
input=input,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
hooks=hooks,
|
||||
run_config=run_config,
|
||||
previous_response_id=previous_response_id,
|
||||
session=session,
|
||||
)
|
||||
except AgentsException as e:
|
||||
# In order for workflow failures to properly fail the workflow, we need to rewrap them in
|
||||
# a Temporal error
|
||||
if e.__cause__ and workflow.is_failure_exception(e.__cause__):
|
||||
reraise = AgentsWorkflowError(
|
||||
f"Workflow failure exception in Agents Framework: {e}"
|
||||
)
|
||||
reraise.__traceback__ = e.__traceback__
|
||||
raise reraise from e.__cause__
|
||||
else:
|
||||
raise e
|
||||
|
||||
def run_sync(
|
||||
self,
|
||||
starting_agent: Agent[TContext],
|
||||
input: str | list[TResponseInputItem] | RunState[TContext],
|
||||
**kwargs: Any,
|
||||
) -> RunResult:
|
||||
"""Run the agent synchronously (not supported in Temporal workflows)."""
|
||||
if not workflow.in_workflow():
|
||||
return self._runner.run_sync(
|
||||
starting_agent,
|
||||
input,
|
||||
**kwargs,
|
||||
)
|
||||
raise RuntimeError("Temporal workflows do not support synchronous model calls.")
|
||||
|
||||
def run_streamed(
|
||||
self,
|
||||
starting_agent: Agent[TContext],
|
||||
input: str | list[TResponseInputItem] | RunState[TContext],
|
||||
**kwargs: Any,
|
||||
) -> RunResultStreaming:
|
||||
"""Run the agent with streaming responses (not supported in Temporal workflows)."""
|
||||
if not workflow.in_workflow():
|
||||
return self._runner.run_streamed(
|
||||
starting_agent,
|
||||
input,
|
||||
**kwargs,
|
||||
)
|
||||
raise RuntimeError("Temporal workflows do not support streaming.")
|
||||
|
||||
|
||||
def _model_name(agent: Agent[Any]) -> str | None:
|
||||
name = agent.model
|
||||
if name is not None and not isinstance(name, str):
|
||||
raise ValueError("Temporal workflows require a model name to be a string in the agent.")
|
||||
return name
|
||||
@@ -0,0 +1,206 @@
|
||||
# vendored pre-release code; type errors are misreported due to patching
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from openai.types.responses.response_prompt_param import ResponsePromptParam
|
||||
from temporalio import workflow
|
||||
from temporalio.contrib.openai_agents._invoke_model_activity import (
|
||||
ActivityModelInput,
|
||||
AgentOutputSchemaInput,
|
||||
ApplyPatchToolInput,
|
||||
FunctionToolInput,
|
||||
HandoffInput,
|
||||
HostedMCPToolInput,
|
||||
ModelActivity,
|
||||
ModelTracingInput,
|
||||
ShellToolInput,
|
||||
ToolInput,
|
||||
)
|
||||
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
|
||||
|
||||
from agents import (
|
||||
Agent,
|
||||
AgentOutputSchema,
|
||||
AgentOutputSchemaBase,
|
||||
CodeInterpreterTool,
|
||||
FileSearchTool,
|
||||
FunctionTool,
|
||||
Handoff,
|
||||
HostedMCPTool,
|
||||
ImageGenerationTool,
|
||||
Model,
|
||||
ModelResponse,
|
||||
ModelSettings,
|
||||
ModelTracing,
|
||||
Tool,
|
||||
TResponseInputItem,
|
||||
WebSearchTool,
|
||||
)
|
||||
from agents.items import TResponseStreamEvent
|
||||
from agents.tool import ApplyPatchTool, LocalShellTool, ShellTool, ToolSearchTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _TemporalModelStub(Model): # type:ignore[reportUnusedClass]
|
||||
"""A stub that allows invoking models as Temporal activities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str | None,
|
||||
*,
|
||||
model_params: ModelActivityParameters,
|
||||
agent: Agent[Any] | None,
|
||||
) -> None:
|
||||
self.model_name = model_name
|
||||
self.model_params = model_params
|
||||
self.agent = agent
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem],
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> ModelResponse:
|
||||
def make_tool_info(tool: Tool) -> ToolInput:
|
||||
if isinstance(
|
||||
tool,
|
||||
FileSearchTool
|
||||
| WebSearchTool
|
||||
| ImageGenerationTool
|
||||
| CodeInterpreterTool
|
||||
| LocalShellTool
|
||||
| ToolSearchTool,
|
||||
):
|
||||
return tool
|
||||
elif isinstance(tool, ShellTool):
|
||||
return ShellToolInput(
|
||||
name=tool.name,
|
||||
environment=tool.environment,
|
||||
)
|
||||
elif isinstance(tool, ApplyPatchTool):
|
||||
return ApplyPatchToolInput(name=tool.name)
|
||||
elif isinstance(tool, HostedMCPTool):
|
||||
return HostedMCPToolInput(tool_config=tool.tool_config)
|
||||
elif isinstance(tool, FunctionTool):
|
||||
return FunctionToolInput(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
params_json_schema=tool.params_json_schema,
|
||||
strict_json_schema=tool.strict_json_schema,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported tool type: {tool.name}")
|
||||
|
||||
tool_infos = [make_tool_info(x) for x in tools]
|
||||
handoff_infos = [
|
||||
HandoffInput(
|
||||
tool_name=x.tool_name,
|
||||
tool_description=x.tool_description,
|
||||
input_json_schema=x.input_json_schema,
|
||||
agent_name=x.agent_name,
|
||||
strict_json_schema=x.strict_json_schema,
|
||||
)
|
||||
for x in handoffs
|
||||
]
|
||||
if output_schema is not None and not isinstance(output_schema, AgentOutputSchema):
|
||||
raise TypeError(
|
||||
f"Only AgentOutputSchema is supported by Temporal Model, got {type(output_schema).__name__}"
|
||||
)
|
||||
agent_output_schema = output_schema
|
||||
output_schema_input = (
|
||||
None
|
||||
if agent_output_schema is None
|
||||
else AgentOutputSchemaInput(
|
||||
output_type_name=agent_output_schema.name(),
|
||||
is_wrapped=agent_output_schema._is_wrapped,
|
||||
output_schema=agent_output_schema.json_schema()
|
||||
if not agent_output_schema.is_plain_text()
|
||||
else None,
|
||||
strict_json_schema=agent_output_schema.is_strict_json_schema(),
|
||||
)
|
||||
)
|
||||
|
||||
activity_input = ActivityModelInput(
|
||||
model_name=self.model_name,
|
||||
system_instructions=system_instructions,
|
||||
input=input,
|
||||
model_settings=model_settings,
|
||||
tools=tool_infos,
|
||||
output_schema=output_schema_input,
|
||||
handoffs=handoff_infos,
|
||||
tracing=ModelTracingInput(tracing.value),
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
if self.model_params.summary_override:
|
||||
summary = (
|
||||
self.model_params.summary_override
|
||||
if isinstance(self.model_params.summary_override, str)
|
||||
else (
|
||||
self.model_params.summary_override.provide(
|
||||
self.agent, system_instructions, input
|
||||
)
|
||||
)
|
||||
)
|
||||
elif self.agent:
|
||||
summary = self.agent.name
|
||||
else:
|
||||
summary = None
|
||||
|
||||
if self.model_params.use_local_activity:
|
||||
return await workflow.execute_local_activity_method(
|
||||
ModelActivity.invoke_model_activity,
|
||||
activity_input,
|
||||
summary=summary,
|
||||
schedule_to_close_timeout=self.model_params.schedule_to_close_timeout,
|
||||
schedule_to_start_timeout=self.model_params.schedule_to_start_timeout,
|
||||
start_to_close_timeout=self.model_params.start_to_close_timeout,
|
||||
retry_policy=self.model_params.retry_policy,
|
||||
cancellation_type=self.model_params.cancellation_type,
|
||||
)
|
||||
else:
|
||||
return await workflow.execute_activity_method(
|
||||
ModelActivity.invoke_model_activity,
|
||||
activity_input,
|
||||
summary=summary,
|
||||
task_queue=self.model_params.task_queue,
|
||||
schedule_to_close_timeout=self.model_params.schedule_to_close_timeout,
|
||||
schedule_to_start_timeout=self.model_params.schedule_to_start_timeout,
|
||||
start_to_close_timeout=self.model_params.start_to_close_timeout,
|
||||
heartbeat_timeout=self.model_params.heartbeat_timeout,
|
||||
retry_policy=self.model_params.retry_policy,
|
||||
cancellation_type=self.model_params.cancellation_type,
|
||||
versioning_intent=self.model_params.versioning_intent,
|
||||
priority=self.model_params.priority,
|
||||
)
|
||||
|
||||
def stream_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem],
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
raise NotImplementedError("Temporal model doesn't support streams yet")
|
||||
@@ -0,0 +1,332 @@
|
||||
# vendored pre-release code; type errors are misreported due to patching
|
||||
# mypy: ignore-errors
|
||||
"""Initialize Temporal OpenAI Agents overrides."""
|
||||
|
||||
import dataclasses
|
||||
import typing
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import timedelta
|
||||
|
||||
from temporalio.contrib.openai_agents._invoke_model_activity import ModelActivity
|
||||
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
|
||||
from temporalio.contrib.openai_agents._openai_runner import (
|
||||
TemporalOpenAIRunner,
|
||||
)
|
||||
from temporalio.contrib.openai_agents._temporal_trace_provider import (
|
||||
TemporalTraceProvider,
|
||||
)
|
||||
from temporalio.contrib.openai_agents._trace_interceptor import (
|
||||
OpenAIAgentsContextPropagationInterceptor,
|
||||
)
|
||||
from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
|
||||
from temporalio.contrib.opentelemetry._tracer_provider import ReplaySafeTracerProvider
|
||||
from temporalio.contrib.pydantic import (
|
||||
PydanticPayloadConverter,
|
||||
ToJsonOptions,
|
||||
)
|
||||
from temporalio.converter import (
|
||||
DataConverter,
|
||||
DefaultPayloadConverter,
|
||||
)
|
||||
from temporalio.plugin import SimplePlugin
|
||||
from temporalio.worker import WorkflowRunner
|
||||
from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner
|
||||
|
||||
from agents import ModelProvider, Trace, set_trace_provider
|
||||
from agents.run import get_default_agent_runner, set_default_agent_runner
|
||||
from agents.tracing import get_trace_provider
|
||||
from agents.tracing.provider import DefaultTraceProvider
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from temporalio.contrib.openai_agents import (
|
||||
SandboxClientProvider,
|
||||
StatefulMCPServerProvider,
|
||||
StatelessMCPServerProvider,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _set_open_ai_agent_temporal_overrides(
|
||||
model_params: ModelActivityParameters,
|
||||
start_spans_in_replay: bool = False,
|
||||
):
|
||||
previous_runner = get_default_agent_runner()
|
||||
previous_trace_provider = get_trace_provider()
|
||||
provider = TemporalTraceProvider(
|
||||
start_spans_in_replay=start_spans_in_replay,
|
||||
)
|
||||
|
||||
try:
|
||||
set_default_agent_runner(TemporalOpenAIRunner(model_params))
|
||||
set_trace_provider(provider)
|
||||
yield provider
|
||||
finally:
|
||||
set_default_agent_runner(previous_runner)
|
||||
set_trace_provider(previous_trace_provider or DefaultTraceProvider())
|
||||
|
||||
|
||||
class OpenAIPayloadConverter(PydanticPayloadConverter):
|
||||
"""PayloadConverter for OpenAI agents."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a payload converter."""
|
||||
super().__init__(ToJsonOptions(exclude_unset=True))
|
||||
|
||||
|
||||
def _data_converter(converter: DataConverter | None) -> DataConverter:
|
||||
if converter is None:
|
||||
return DataConverter(payload_converter_class=OpenAIPayloadConverter)
|
||||
elif converter.payload_converter_class is DefaultPayloadConverter:
|
||||
return dataclasses.replace(converter, payload_converter_class=OpenAIPayloadConverter)
|
||||
elif not isinstance(converter.payload_converter, OpenAIPayloadConverter):
|
||||
raise ValueError("The payload converter must be of type OpenAIPayloadConverter.")
|
||||
return converter
|
||||
|
||||
|
||||
class OpenAIAgentsPlugin(SimplePlugin):
|
||||
"""Temporal plugin for integrating OpenAI agents with Temporal workflows.
|
||||
|
||||
This plugin provides seamless integration between the OpenAI Agents SDK and
|
||||
Temporal workflows. It automatically configures the necessary interceptors,
|
||||
activities, and data converters to enable OpenAI agents to run within
|
||||
Temporal workflows with proper tracing and model execution.
|
||||
|
||||
The plugin:
|
||||
1. Configures the Pydantic data converter for type-safe serialization
|
||||
2. Sets up tracing interceptors for OpenAI agent interactions
|
||||
3. Registers model execution activities
|
||||
4. Automatically registers MCP server activities and manages their lifecycles
|
||||
5. Manages the OpenAI agent runtime overrides during worker execution
|
||||
|
||||
Example:
|
||||
>>> from temporalio.client import Client
|
||||
>>> from temporalio.worker import Worker
|
||||
>>> from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters, StatelessMCPServerProvider
|
||||
>>> from agents.mcp import MCPServerStdio
|
||||
>>> from datetime import timedelta
|
||||
>>>
|
||||
>>> # Configure model parameters
|
||||
>>> model_params = ModelActivityParameters(
|
||||
... start_to_close_timeout=timedelta(seconds=30),
|
||||
... retry_policy=RetryPolicy(maximum_attempts=3)
|
||||
... )
|
||||
>>>
|
||||
>>> # Create MCP servers
|
||||
>>> filesystem_server = StatelessMCPServerProvider(MCPServerStdio(
|
||||
... name="Filesystem Server",
|
||||
... params={"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]}
|
||||
... ))
|
||||
>>>
|
||||
>>> # Create plugin with MCP servers
|
||||
>>> plugin = OpenAIAgentsPlugin(
|
||||
... model_params=model_params,
|
||||
... mcp_server_providers=[filesystem_server]
|
||||
... )
|
||||
>>>
|
||||
>>> # Use with client and worker
|
||||
>>> client = await Client.connect(
|
||||
... "localhost:7233",
|
||||
... plugins=[plugin]
|
||||
... )
|
||||
>>> worker = Worker(
|
||||
... client,
|
||||
... task_queue="my-task-queue",
|
||||
... workflows=[MyWorkflow],
|
||||
... )
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_params: ModelActivityParameters | None = None,
|
||||
model_provider: ModelProvider | None = None,
|
||||
mcp_server_providers: Sequence[
|
||||
"StatelessMCPServerProvider | StatefulMCPServerProvider"
|
||||
] = (),
|
||||
sandbox_clients: Sequence["SandboxClientProvider"] = (),
|
||||
register_activities: bool = True,
|
||||
add_temporal_spans: bool = True,
|
||||
use_otel_instrumentation: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the OpenAI agents plugin.
|
||||
|
||||
Args:
|
||||
model_params: Configuration parameters for Temporal activity execution
|
||||
of model calls. If None, default parameters will be used.
|
||||
model_provider: Optional model provider for custom model implementations.
|
||||
Useful for testing or custom model integrations.
|
||||
mcp_server_providers: Sequence of MCP servers to automatically register with the worker.
|
||||
Each server will be wrapped in a TemporalMCPServer if not already wrapped,
|
||||
and their activities will be automatically registered with the worker.
|
||||
The plugin manages the connection lifecycle of these servers.
|
||||
sandbox_clients: Sequence of named sandbox client providers to register
|
||||
on the worker. Each provider pairs a unique name with a real
|
||||
``BaseSandboxClient`` (e.g. ``DaytonaSandboxClient``,
|
||||
``UnixLocalSandboxClient``). On the workflow side, use
|
||||
:func:`~temporalio.contrib.openai_agents.workflow.temporal_sandbox_client`
|
||||
with the matching name to target the correct backend.
|
||||
register_activities: Whether to register activities during the worker execution.
|
||||
This can be disabled on some workers to allow a separation of workflows and activities
|
||||
but should not be disabled on all workers, or agents will not be able to progress.
|
||||
add_temporal_spans: Whether to add temporal spans to traces
|
||||
use_otel_instrumentation: If set to true, enable open telemetry instrumentation.
|
||||
Warning: use_otel_instrumentation is experimental and behavior may change in future versions.
|
||||
Use with caution in production environments.
|
||||
|
||||
"""
|
||||
if model_params is None:
|
||||
model_params = ModelActivityParameters()
|
||||
|
||||
# For the default provider, we provide a default start_to_close_timeout of 60 seconds.
|
||||
# Other providers will need to define their own.
|
||||
if (
|
||||
model_params.start_to_close_timeout is None
|
||||
and model_params.schedule_to_close_timeout is None
|
||||
):
|
||||
if model_provider is None:
|
||||
model_params.start_to_close_timeout = timedelta(seconds=60)
|
||||
else:
|
||||
raise ValueError(
|
||||
"When configuring a custom provider, the model activity must have start_to_close_timeout or schedule_to_close_timeout"
|
||||
)
|
||||
|
||||
# Store OTEL configuration for later setup
|
||||
self._instrumented = False
|
||||
self._use_otel_instrumentation = use_otel_instrumentation
|
||||
|
||||
# Delay activity construction until they are actually needed
|
||||
def add_activities(
|
||||
activities: Sequence[Callable] | None,
|
||||
) -> Sequence[Callable]:
|
||||
if not register_activities:
|
||||
return activities or []
|
||||
|
||||
new_activities = [ModelActivity(model_provider).invoke_model_activity]
|
||||
|
||||
server_names = [server.name for server in mcp_server_providers]
|
||||
if len(server_names) != len(set(server_names)):
|
||||
raise ValueError(
|
||||
"More than one mcp server registered with the same name. Please provide unique names."
|
||||
)
|
||||
|
||||
for mcp_server in mcp_server_providers:
|
||||
new_activities.extend(mcp_server._get_activities())
|
||||
|
||||
sandbox_names = [sc.name for sc in sandbox_clients]
|
||||
if len(sandbox_names) != len(set(sandbox_names)):
|
||||
raise ValueError(
|
||||
"More than one sandbox client registered with the same name. Please provide unique names."
|
||||
)
|
||||
|
||||
for sandbox_provider in sandbox_clients:
|
||||
new_activities.extend(sandbox_provider._get_activities())
|
||||
|
||||
return list(activities or []) + new_activities
|
||||
|
||||
def workflow_runner(runner: WorkflowRunner | None) -> WorkflowRunner:
|
||||
if not runner:
|
||||
raise ValueError("No WorkflowRunner provided to the OpenAI plugin.")
|
||||
|
||||
# If in sandbox, add additional passthrough
|
||||
if isinstance(runner, SandboxedWorkflowRunner):
|
||||
return dataclasses.replace(
|
||||
runner,
|
||||
restrictions=runner.restrictions.with_passthrough_modules(
|
||||
"openai", "agents", "mcp"
|
||||
),
|
||||
)
|
||||
return runner
|
||||
|
||||
if not use_otel_instrumentation:
|
||||
interceptor = OpenAIAgentsContextPropagationInterceptor(
|
||||
add_temporal_spans=add_temporal_spans,
|
||||
)
|
||||
else:
|
||||
from opentelemetry import trace as otel_trace
|
||||
|
||||
from ._otel_trace_interceptor import (
|
||||
OTelOpenAIAgentsContextPropagationInterceptor,
|
||||
)
|
||||
|
||||
provider = otel_trace.get_tracer_provider()
|
||||
if not isinstance(provider, ReplaySafeTracerProvider):
|
||||
raise ValueError(
|
||||
"Global tracer provider must a ReplaySafeTracerProvider. Use temporalio.contrib.opentelemtry.create_trace_provider to create one."
|
||||
)
|
||||
|
||||
interceptor = OTelOpenAIAgentsContextPropagationInterceptor(
|
||||
add_temporal_spans=add_temporal_spans,
|
||||
otel_id_generator=provider.id_generator(),
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def run_context() -> AsyncIterator[None]:
|
||||
with self.tracing_context():
|
||||
with _set_open_ai_agent_temporal_overrides(
|
||||
model_params,
|
||||
start_spans_in_replay=use_otel_instrumentation,
|
||||
):
|
||||
yield
|
||||
|
||||
super().__init__(
|
||||
name="OpenAIAgentsPlugin",
|
||||
data_converter=_data_converter,
|
||||
interceptors=[interceptor],
|
||||
activities=add_activities,
|
||||
workflow_runner=workflow_runner,
|
||||
workflow_failure_exception_types=[AgentsWorkflowError],
|
||||
run_context=lambda: run_context(),
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def tracing_context(self) -> Iterator[None]:
|
||||
"""Context manager for setting up OpenAI Agents tracing instrumentation.
|
||||
|
||||
This should be called if AgentsSDK traces and/or spans are started outside of the context of a worker.
|
||||
For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with env.openai_agents_plugin.tracing_context():
|
||||
with trace("External trace"):
|
||||
with custom_span("External span"):
|
||||
workflow_handle = await new_client.start_workflow(
|
||||
...
|
||||
)
|
||||
|
||||
Yields:
|
||||
Context with tracing instrumentation enabled.
|
||||
"""
|
||||
# Set up OTEL instrumentation if exporters are provided
|
||||
otel_instrumentor = None
|
||||
if self._use_otel_instrumentation and not self._instrumented:
|
||||
from openinference.instrumentation.openai_agents import (
|
||||
OpenAIAgentsInstrumentor,
|
||||
)
|
||||
from openinference.instrumentation.openai_agents._processor import (
|
||||
OpenInferenceTracingProcessor,
|
||||
)
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.context import attach
|
||||
from opentelemetry.trace import set_span_in_context
|
||||
|
||||
# Unfortunate monkey patching is needed to ensure the trace is set in context so we can propagate it.
|
||||
original_on_trace_start = OpenInferenceTracingProcessor.on_trace_start
|
||||
|
||||
def on_trace_start(self, trace: Trace) -> None: # type: ignore[reportMissingParameterType]
|
||||
original_on_trace_start(self, trace)
|
||||
otel_span = self._root_spans[trace.trace_id]
|
||||
attach(set_span_in_context(otel_span))
|
||||
|
||||
OpenInferenceTracingProcessor.on_trace_start = on_trace_start # type:ignore[method-assign]
|
||||
|
||||
# Set up instrumentor
|
||||
otel_instrumentor = OpenAIAgentsInstrumentor()
|
||||
otel_instrumentor.instrument(tracer_provider=trace.get_tracer_provider())
|
||||
self._instrumented = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Clean up OTEL instrumentation
|
||||
if otel_instrumentor is not None:
|
||||
otel_instrumentor.uninstrument()
|
||||
@@ -0,0 +1,32 @@
|
||||
# TEMPORARY: Patch helpers for unreleased Temporal OpenAI Agents plugin sandbox support.
|
||||
# Remove this file (and _vendored_plugin/) once temporalio ships with sandbox support baked in
|
||||
# (i.e. `temporalio.contrib.openai_agents.sandbox` exists in the released package).
|
||||
|
||||
# Vendored plugin files checked into this repo
|
||||
_plugin_src := justfile_directory() / "_vendored_plugin"
|
||||
|
||||
# Patch the installed temporalio package with local plugin changes
|
||||
[private]
|
||||
patch:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
plugin_dst="$(uv run python -c "import temporalio, os; print(os.path.join(os.path.dirname(temporalio.__file__), 'contrib', 'openai_agents'))")"
|
||||
patch_marker="$plugin_dst/.patched"
|
||||
if [ ! -f "$patch_marker" ]; then
|
||||
echo "Patching installed temporalio plugin from vendored source..."
|
||||
cp "{{_plugin_src}}"/*.py "$plugin_dst/"
|
||||
cp -r "{{_plugin_src}}/sandbox" "$plugin_dst/"
|
||||
touch "$patch_marker"
|
||||
echo "Done. Plugin patched with sandbox support."
|
||||
fi
|
||||
|
||||
# Force re-patch (e.g. after updating vendored files)
|
||||
[private]
|
||||
repatch: unpatch patch
|
||||
|
||||
# Restore the installed temporalio plugin to its original state
|
||||
[private]
|
||||
unpatch:
|
||||
@echo "Restoring original temporalio plugin..."
|
||||
@uv pip install --reinstall --no-deps temporalio
|
||||
@echo "Done. Plugin restored."
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Sandbox support for Temporal OpenAI Agents.
|
||||
|
||||
This subpackage contains the :class:`SandboxClientProvider` (for registering
|
||||
sandbox backends on the worker) and internal implementation details for
|
||||
routing sandbox lifecycle and I/O operations through Temporal activities.
|
||||
"""
|
||||
+62
@@ -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()
|
||||
+163
@@ -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
|
||||
+209
@@ -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,
|
||||
]
|
||||
+123
@@ -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)
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
# vendored pre-release code; type errors are misreported due to patching
|
||||
# mypy: ignore-errors
|
||||
"""Temporal-aware sandbox session that routes all I/O through Temporal activities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from temporalio import workflow
|
||||
from temporalio.contrib.openai_agents.sandbox._temporal_activity_models import (
|
||||
ExecArgs,
|
||||
ExecResult as ExecResultModel,
|
||||
HydrateWorkspaceArgs,
|
||||
PersistWorkspaceArgs,
|
||||
PersistWorkspaceResult,
|
||||
PtyExecStartArgs,
|
||||
PtyExecUpdateResult,
|
||||
PtyWriteStdinArgs,
|
||||
ReadArgs,
|
||||
ReadResult,
|
||||
RunningArgs,
|
||||
RunningResult,
|
||||
StartArgs,
|
||||
StopArgs,
|
||||
VoidResult,
|
||||
WriteArgs,
|
||||
)
|
||||
from temporalio.workflow import ActivityConfig
|
||||
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.session.pty_types import PtyExecUpdate
|
||||
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
|
||||
from agents.sandbox.types import ExecResult, User
|
||||
|
||||
|
||||
class TemporalSandboxSession(BaseSandboxSession):
|
||||
"""A BaseSandboxSession that routes all I/O through Temporal activities.
|
||||
|
||||
This class is fully stateless with respect to the physical sandbox -- it
|
||||
holds only the serializable ``SandboxSessionState`` and a ``supports_pty``
|
||||
flag (both provided by the worker-side ``SessionResult``).
|
||||
|
||||
Activity names are prefixed with the provider ``name`` so that dispatches
|
||||
reach the correct sandbox backend's activities on the worker.
|
||||
|
||||
Each activity receives a single Pydantic model instance. Because the Temporal
|
||||
client is configured with ``pydantic_data_converter``, all fields are
|
||||
serialized and deserialized automatically.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
config: ActivityConfig,
|
||||
state: SandboxSessionState,
|
||||
supports_pty_flag: bool = True,
|
||||
) -> None:
|
||||
self._name = name
|
||||
self._config = config
|
||||
self._state = state
|
||||
self._supports_pty = supports_pty_flag
|
||||
|
||||
@property
|
||||
def state(self) -> SandboxSessionState:
|
||||
return self._state
|
||||
|
||||
@state.setter
|
||||
def state(self, value: SandboxSessionState) -> None:
|
||||
self._state = value
|
||||
|
||||
async def exec(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
shell: bool | list[str] = True,
|
||||
user: str | User | None = None,
|
||||
) -> ExecResult:
|
||||
result: ExecResultModel = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_exec",
|
||||
arg=ExecArgs(
|
||||
state=self.state,
|
||||
command=[str(c) for c in command],
|
||||
timeout=timeout,
|
||||
shell=shell,
|
||||
user=user,
|
||||
),
|
||||
result_type=ExecResultModel,
|
||||
**self._config,
|
||||
)
|
||||
return ExecResult(stdout=result.stdout, stderr=result.stderr, exit_code=result.exit_code)
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
raise NotImplementedError("TemporalSandboxSession overrides exec() directly")
|
||||
|
||||
async def read(self, path: Path) -> io.IOBase:
|
||||
result: ReadResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_read",
|
||||
arg=ReadArgs(state=self.state, path=str(path)),
|
||||
result_type=ReadResult,
|
||||
**self._config,
|
||||
)
|
||||
return io.BytesIO(result.data)
|
||||
|
||||
async def write(self, path: Path, data: io.IOBase) -> None:
|
||||
_: VoidResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_write",
|
||||
arg=WriteArgs(state=self.state, path=str(path), data=data.read()),
|
||||
result_type=VoidResult,
|
||||
**self._config,
|
||||
)
|
||||
|
||||
async def running(self) -> bool:
|
||||
result: RunningResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_running",
|
||||
arg=RunningArgs(state=self.state),
|
||||
result_type=RunningResult,
|
||||
**self._config,
|
||||
)
|
||||
return result.is_running
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
_: VoidResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_shutdown",
|
||||
arg=StopArgs(state=self.state),
|
||||
result_type=VoidResult,
|
||||
**self._config,
|
||||
)
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
result: PersistWorkspaceResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_persist_workspace",
|
||||
arg=PersistWorkspaceArgs(state=self.state),
|
||||
result_type=PersistWorkspaceResult,
|
||||
**self._config,
|
||||
)
|
||||
return io.BytesIO(result.data)
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
_: VoidResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_hydrate_workspace",
|
||||
arg=HydrateWorkspaceArgs(state=self.state, data=data.read()),
|
||||
result_type=VoidResult,
|
||||
**self._config,
|
||||
)
|
||||
|
||||
def supports_pty(self) -> bool:
|
||||
return self._supports_pty
|
||||
|
||||
async def pty_exec_start(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
shell: bool | list[str] = True,
|
||||
user: str | User | None = None,
|
||||
tty: bool = False,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
result: PtyExecUpdateResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_pty_exec_start",
|
||||
arg=PtyExecStartArgs(
|
||||
state=self.state,
|
||||
command=[str(c) for c in command],
|
||||
timeout=timeout,
|
||||
shell=shell,
|
||||
user=user,
|
||||
tty=tty,
|
||||
yield_time_s=yield_time_s,
|
||||
max_output_tokens=max_output_tokens,
|
||||
),
|
||||
result_type=PtyExecUpdateResult,
|
||||
**self._config,
|
||||
)
|
||||
return PtyExecUpdate(
|
||||
process_id=result.process_id,
|
||||
output=result.output,
|
||||
exit_code=result.exit_code,
|
||||
original_token_count=result.original_token_count,
|
||||
)
|
||||
|
||||
async def pty_write_stdin(
|
||||
self,
|
||||
*,
|
||||
session_id: int,
|
||||
chars: str,
|
||||
yield_time_s: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
) -> PtyExecUpdate:
|
||||
result: PtyExecUpdateResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_pty_write_stdin",
|
||||
arg=PtyWriteStdinArgs(
|
||||
state=self.state,
|
||||
session_id=session_id,
|
||||
chars=chars,
|
||||
yield_time_s=yield_time_s,
|
||||
max_output_tokens=max_output_tokens,
|
||||
),
|
||||
result_type=PtyExecUpdateResult,
|
||||
**self._config,
|
||||
)
|
||||
return PtyExecUpdate(
|
||||
process_id=result.process_id,
|
||||
output=result.output,
|
||||
exit_code=result.exit_code,
|
||||
original_token_count=result.original_token_count,
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
_: VoidResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_start",
|
||||
arg=StartArgs(state=self.state),
|
||||
result_type=VoidResult,
|
||||
**self._config,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
_: VoidResult = await workflow.execute_activity(
|
||||
f"{self._name}-sandbox_session_stop",
|
||||
arg=StopArgs(state=self.state),
|
||||
result_type=VoidResult,
|
||||
**self._config,
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
# vendored pre-release code; type errors are misreported due to patching
|
||||
# mypy: ignore-errors
|
||||
"""Workflow-specific primitives for working with the OpenAI Agents SDK in a workflow context"""
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import typing
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import nexusrpc
|
||||
from temporalio import activity, workflow as temporal_workflow
|
||||
from temporalio.common import Priority, RetryPolicy
|
||||
from temporalio.exceptions import ApplicationError, TemporalError
|
||||
from temporalio.workflow import (
|
||||
ActivityCancellationType,
|
||||
ActivityConfig,
|
||||
VersioningIntent,
|
||||
)
|
||||
|
||||
from agents import (
|
||||
RunContextWrapper,
|
||||
Tool,
|
||||
)
|
||||
from agents.function_schema import function_schema
|
||||
from agents.tool import (
|
||||
FunctionTool,
|
||||
)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from agents.mcp import MCPServer
|
||||
|
||||
|
||||
def activity_as_tool(
|
||||
fn: Callable,
|
||||
*,
|
||||
task_queue: str | None = None,
|
||||
schedule_to_close_timeout: timedelta | None = None,
|
||||
schedule_to_start_timeout: timedelta | None = None,
|
||||
start_to_close_timeout: timedelta | None = None,
|
||||
heartbeat_timeout: timedelta | None = None,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL,
|
||||
activity_id: str | None = None,
|
||||
versioning_intent: VersioningIntent | None = None,
|
||||
summary: str | None = None,
|
||||
priority: Priority = Priority.default,
|
||||
strict_json_schema: bool = True,
|
||||
) -> Tool:
|
||||
"""Convert a single Temporal activity function to an OpenAI agent tool.
|
||||
|
||||
This function takes a Temporal activity function and converts it into an
|
||||
OpenAI agent tool that can be used by the agent to execute the activity
|
||||
during workflow execution. The tool will automatically handle the conversion
|
||||
of inputs and outputs between the agent and the activity. Note that if you take a context,
|
||||
mutation will not be persisted, as the activity may not be running in the same location.
|
||||
|
||||
For undocumented arguments, refer to :py:mod:`workflow` and :py:meth:`start_activity`
|
||||
|
||||
Args:
|
||||
fn: A Temporal activity function to convert to a tool.
|
||||
strict_json_schema: Whether the tool should follow a strict schema.
|
||||
See https://openai.github.io/openai-agents-python/ref/tool/#agents.tool.FunctionTool.strict_json_schema
|
||||
|
||||
|
||||
Returns:
|
||||
An OpenAI agent tool that wraps the provided activity.
|
||||
|
||||
Raises:
|
||||
ApplicationError: If the function is not properly decorated as a Temporal activity.
|
||||
|
||||
Example:
|
||||
>>> @activity.defn
|
||||
>>> def process_data(input: str) -> str:
|
||||
... return f"Processed: {input}"
|
||||
>>>
|
||||
>>> # Create tool with custom activity options
|
||||
>>> tool = activity_as_tool(
|
||||
... process_data,
|
||||
... start_to_close_timeout=timedelta(seconds=30),
|
||||
... retry_policy=RetryPolicy(maximum_attempts=3),
|
||||
... heartbeat_timeout=timedelta(seconds=10)
|
||||
... )
|
||||
>>> # Use tool with an OpenAI agent
|
||||
"""
|
||||
ret = activity._Definition.from_callable(fn)
|
||||
if not ret:
|
||||
raise ApplicationError(
|
||||
"Bare function without tool and activity decorators is not supported",
|
||||
"invalid_tool",
|
||||
)
|
||||
if ret.name is None:
|
||||
raise ApplicationError(
|
||||
"Input activity must have a name to be made into a tool",
|
||||
"invalid_tool",
|
||||
)
|
||||
# If the provided callable has a first argument of `self`, partially apply it with the same metadata
|
||||
# The actual instance will be picked up by the activity execution, the partially applied function will never actually be executed
|
||||
params = list(inspect.signature(fn).parameters.keys())
|
||||
if len(params) > 0 and params[0] == "self":
|
||||
partial = functools.partial(fn, None)
|
||||
partial.__name__ = fn.__name__
|
||||
partial.__annotations__ = fn.__annotations__
|
||||
setattr(
|
||||
partial,
|
||||
"__temporal_activity_definition",
|
||||
getattr(fn, "__temporal_activity_definition"),
|
||||
)
|
||||
partial.__doc__ = fn.__doc__
|
||||
fn = partial
|
||||
schema = function_schema(fn)
|
||||
|
||||
async def run_activity(ctx: RunContextWrapper[Any], input: str) -> Any:
|
||||
try:
|
||||
json_data = json.loads(input)
|
||||
except Exception as e:
|
||||
raise ApplicationError(f"Invalid JSON input for tool {schema.name}: {input}") from e
|
||||
|
||||
# Activities don't support keyword only arguments, so we can ignore the kwargs_dict return
|
||||
args, _ = schema.to_call_args(schema.params_pydantic_model(**json_data))
|
||||
|
||||
# Add the context to the arguments if it takes that
|
||||
if schema.takes_context:
|
||||
args = [ctx] + args
|
||||
result = await temporal_workflow.execute_activity(
|
||||
ret.name, # type: ignore
|
||||
args=args,
|
||||
task_queue=task_queue,
|
||||
schedule_to_close_timeout=schedule_to_close_timeout,
|
||||
schedule_to_start_timeout=schedule_to_start_timeout,
|
||||
start_to_close_timeout=start_to_close_timeout,
|
||||
heartbeat_timeout=heartbeat_timeout,
|
||||
retry_policy=retry_policy,
|
||||
cancellation_type=cancellation_type,
|
||||
activity_id=activity_id,
|
||||
versioning_intent=versioning_intent,
|
||||
summary=summary or schema.description,
|
||||
priority=priority,
|
||||
)
|
||||
try:
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
raise ToolSerializationError(
|
||||
"You must return a string representation of the tool output, or something we can call str() on"
|
||||
) from e
|
||||
|
||||
return FunctionTool(
|
||||
name=schema.name,
|
||||
description=schema.description or "",
|
||||
params_json_schema=schema.params_json_schema,
|
||||
on_invoke_tool=run_activity,
|
||||
strict_json_schema=strict_json_schema,
|
||||
)
|
||||
|
||||
|
||||
def nexus_operation_as_tool(
|
||||
operation: nexusrpc.Operation[Any, Any],
|
||||
*,
|
||||
service: type[Any],
|
||||
endpoint: str,
|
||||
schedule_to_close_timeout: timedelta | None = None,
|
||||
strict_json_schema: bool = True,
|
||||
) -> Tool:
|
||||
"""Convert a Nexus operation into an OpenAI agent tool.
|
||||
|
||||
This function takes a Nexus operation and converts it into an
|
||||
OpenAI agent tool that can be used by the agent to execute the operation
|
||||
during workflow execution. The tool will automatically handle the conversion
|
||||
of inputs and outputs between the agent and the operation.
|
||||
|
||||
Args:
|
||||
operation: A Nexus operation to convert into a tool.
|
||||
service: The Nexus service class that contains the operation.
|
||||
endpoint: The Nexus endpoint to use for the operation.
|
||||
strict_json_schema: Whether the tool should follow a strict schema
|
||||
|
||||
Returns:
|
||||
An OpenAI agent tool that wraps the provided operation.
|
||||
|
||||
Example:
|
||||
>>> @nexusrpc.service
|
||||
... class WeatherService:
|
||||
... get_weather_object_nexus_operation: nexusrpc.Operation[WeatherInput, Weather]
|
||||
>>>
|
||||
>>> # Create tool with custom activity options
|
||||
>>> tool = nexus_operation_as_tool(
|
||||
... WeatherService.get_weather_object_nexus_operation,
|
||||
... service=WeatherService,
|
||||
... endpoint="weather-service",
|
||||
... )
|
||||
>>> # Use tool with an OpenAI agent
|
||||
"""
|
||||
|
||||
def operation_callable(input: Any): # type: ignore[reportUnusedParameter]
|
||||
raise NotImplementedError("This function definition is used as a type only")
|
||||
|
||||
operation_callable.__annotations__ = {
|
||||
"input": operation.input_type,
|
||||
"return": operation.output_type,
|
||||
}
|
||||
operation_callable.__name__ = operation.name
|
||||
|
||||
schema = function_schema(operation_callable)
|
||||
|
||||
async def run_operation(_ctx: RunContextWrapper[Any], input: str) -> Any:
|
||||
try:
|
||||
json_data = json.loads(input)
|
||||
except Exception as e:
|
||||
raise ApplicationError(f"Invalid JSON input for tool {schema.name}: {input}") from e
|
||||
|
||||
nexus_client = temporal_workflow.create_nexus_client(service=service, endpoint=endpoint)
|
||||
args, _ = schema.to_call_args(schema.params_pydantic_model(**json_data))
|
||||
assert len(args) == 1, "Nexus operations must have exactly one argument"
|
||||
[arg] = args
|
||||
result = await nexus_client.execute_operation(
|
||||
operation,
|
||||
arg,
|
||||
schedule_to_close_timeout=schedule_to_close_timeout,
|
||||
)
|
||||
try:
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
raise ToolSerializationError(
|
||||
"You must return a string representation of the tool output, or something we can call str() on"
|
||||
) from e
|
||||
|
||||
return FunctionTool(
|
||||
name=schema.name,
|
||||
description=schema.description or "",
|
||||
params_json_schema=schema.params_json_schema,
|
||||
on_invoke_tool=run_operation,
|
||||
strict_json_schema=strict_json_schema,
|
||||
)
|
||||
|
||||
|
||||
def temporal_sandbox_client(
|
||||
name: str,
|
||||
config: ActivityConfig | None = None,
|
||||
) -> Any:
|
||||
"""Create a sandbox client reference for use in a Temporal workflow ``RunConfig``.
|
||||
|
||||
This returns a :class:`~agents.sandbox.session.sandbox_client.BaseSandboxClient`
|
||||
that dispatches all sandbox operations as Temporal activities, targeting the
|
||||
:class:`~temporalio.contrib.openai_agents.SandboxClientProvider` registered
|
||||
on the worker with the matching ``name``.
|
||||
|
||||
Example::
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=temporal_sandbox_client("daytona"),
|
||||
options=DaytonaSandboxClientOptions(...),
|
||||
),
|
||||
)
|
||||
|
||||
Args:
|
||||
name: The name of the ``SandboxClientProvider`` registered on the
|
||||
worker. Must match exactly.
|
||||
config: Optional activity configuration for controlling timeouts,
|
||||
retries, etc. Defaults to a 5-minute ``start_to_close_timeout``.
|
||||
"""
|
||||
from temporalio.contrib.openai_agents.sandbox._temporal_sandbox_client import (
|
||||
TemporalSandboxClient,
|
||||
)
|
||||
|
||||
return TemporalSandboxClient(name=name, config=config)
|
||||
|
||||
|
||||
def stateless_mcp_server(
|
||||
name: str,
|
||||
config: ActivityConfig | None = None,
|
||||
cache_tools_list: bool = False,
|
||||
factory_argument: Any | None = None,
|
||||
) -> "MCPServer":
|
||||
"""A stateless MCP server implementation for Temporal workflows.
|
||||
|
||||
This uses a TemporalMCPServer of the same name registered with the OpenAIAgents plugin to implement
|
||||
durable MCP operations statelessly.
|
||||
|
||||
This approach is suitable for simple use cases where connection overhead is acceptable
|
||||
and you don't need to maintain state between operations. It should be preferred to stateful when possible due to its
|
||||
superior durability guarantees.
|
||||
|
||||
Args:
|
||||
name: A string name for the server. Should match that provided in the plugin.
|
||||
config: Optional activity configuration for MCP operation activities.
|
||||
Defaults to 1-minute start-to-close timeout.
|
||||
cache_tools_list: If true, the list of tools will be cached for the duration of the server
|
||||
factory_argument: Optional argument to be provided to the factory when producing an MCPServer
|
||||
"""
|
||||
from temporalio.contrib.openai_agents._mcp import (
|
||||
_StatelessMCPServerReference,
|
||||
)
|
||||
|
||||
return _StatelessMCPServerReference(name, config, cache_tools_list, factory_argument)
|
||||
|
||||
|
||||
def stateful_mcp_server(
|
||||
name: str,
|
||||
config: ActivityConfig | None = None,
|
||||
server_session_config: ActivityConfig | None = None,
|
||||
factory_argument: Any | None = None,
|
||||
) -> AbstractAsyncContextManager["MCPServer"]:
|
||||
"""A stateful MCP server implementation for Temporal workflows.
|
||||
|
||||
This wraps an MCP server to maintain a persistent connection throughout
|
||||
the workflow execution. It creates a dedicated worker that stays connected to
|
||||
the MCP server and processes operations on a dedicated task queue.
|
||||
|
||||
This approach is more efficient for workflows that make multiple MCP calls,
|
||||
as it avoids connection overhead, but requires more resources to maintain
|
||||
the persistent connection and worker.
|
||||
|
||||
The caller will have to handle cases where the dedicated worker fails, as Temporal is
|
||||
unable to seamlessly recreate any lost state in that case.
|
||||
|
||||
Args:
|
||||
name: A string name for the server. Should match that provided in the plugin.
|
||||
config: Optional activity configuration for MCP operation activities.
|
||||
Defaults to 1-minute start-to-close and 30-second schedule-to-start timeouts.
|
||||
server_session_config: Optional activity configuration for the connection activity.
|
||||
Defaults to 1-hour start-to-close timeout.
|
||||
factory_argument: Optional argument to be provided to the factory when producing an MCPServer
|
||||
"""
|
||||
from temporalio.contrib.openai_agents._mcp import (
|
||||
_StatefulMCPServerReference,
|
||||
)
|
||||
|
||||
return _StatefulMCPServerReference(name, config, server_session_config, factory_argument)
|
||||
|
||||
|
||||
class ToolSerializationError(TemporalError):
|
||||
"""Error that occurs when a tool output could not be serialized.
|
||||
|
||||
This exception is raised when a tool (created from an activity or Nexus operation)
|
||||
returns a value that cannot be properly serialized for use by the OpenAI agent.
|
||||
All tool outputs must be convertible to strings for the agent to process them.
|
||||
|
||||
The error typically occurs when:
|
||||
- A tool returns a complex object that doesn't have a meaningful string representation
|
||||
- The returned object cannot be converted using str()
|
||||
- Custom serialization is needed but not implemented
|
||||
|
||||
Example:
|
||||
>>> @activity.defn
|
||||
>>> def problematic_tool() -> ComplexObject:
|
||||
... return ComplexObject() # This might cause ToolSerializationError
|
||||
|
||||
To fix this error, ensure your tool returns string-convertible values or
|
||||
modify the tool to return a string representation of the result.
|
||||
"""
|
||||
|
||||
|
||||
class AgentsWorkflowError(TemporalError):
|
||||
"""Error that occurs when the agents SDK raises an error which should terminate the calling workflow or update."""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Worker startup diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
YELLOW = "\033[1;33m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def print_backend_warnings(registered_names: set[str]) -> None:
|
||||
"""Print a prominent warning banner for any unconfigured sandbox backends."""
|
||||
import docker # type: ignore[import-untyped]
|
||||
|
||||
backend_env = {
|
||||
"daytona": "DAYTONA_API_KEY",
|
||||
"e2b": "E2B_API_KEY",
|
||||
}
|
||||
missing = {name: var for name, var in backend_env.items() if name not in registered_names}
|
||||
try:
|
||||
docker.from_env().ping()
|
||||
except Exception:
|
||||
missing["docker"] = "Docker daemon"
|
||||
|
||||
if not missing:
|
||||
return
|
||||
|
||||
lines = [
|
||||
"WARNING: Some sandbox backends are NOT available.",
|
||||
"Missing:",
|
||||
]
|
||||
for name, var in sorted(missing.items()):
|
||||
lines.append(f" - {name} ({var})")
|
||||
lines.append("The TUI will fail if you select an unconfigured backend.")
|
||||
lines.append("To use them, set the missing env vars and restart the worker.")
|
||||
width = max(len(line) for line in lines) + 4
|
||||
border = "!" * (width + 2)
|
||||
print(f"{YELLOW}{border}{RESET}")
|
||||
for line in lines:
|
||||
print(f"{YELLOW}! {line:<{width - 2}} !{RESET}")
|
||||
print(f"{YELLOW}{border}{RESET}")
|
||||
@@ -0,0 +1,26 @@
|
||||
# Temporal Sandbox Agent
|
||||
|
||||
set dotenv-load
|
||||
set dotenv-path := ".env"
|
||||
|
||||
# TEMPORARY: Import patch helpers until temporalio ships with sandbox support.
|
||||
# Remove this import (and patch_plugin.justfile) once the released package
|
||||
# includes `temporalio.contrib.openai_agents.sandbox`.
|
||||
import '_vendored_plugin/patch_plugin.justfile'
|
||||
|
||||
# Ensure extras are installed
|
||||
[private]
|
||||
sync:
|
||||
@uv sync --extra temporal --extra daytona --extra e2b --extra docker 2>&1 | grep -v "^Audited\|^Resolved" || true
|
||||
|
||||
# Start the local Temporal dev server
|
||||
temporal:
|
||||
temporal server start-dev
|
||||
|
||||
# Start the Temporal worker
|
||||
worker: sync patch
|
||||
uv run --extra temporal --extra daytona --extra e2b --extra docker python temporal_sandbox_agent.py worker
|
||||
|
||||
# Start the TUI client
|
||||
tui: sync patch
|
||||
uv run --extra temporal --extra daytona --extra e2b --extra docker python temporal_sandbox_agent.py run
|
||||
@@ -0,0 +1,724 @@
|
||||
"""Temporal Sandbox agent example.
|
||||
|
||||
Runs a SandboxAgent as a durable Temporal workflow. The workflow is long-lived
|
||||
and conversational: after processing each turn it idles waiting for the next
|
||||
user message. Workflows persist indefinitely in Temporal. A separate session
|
||||
manager workflow (``temporal_session_manager.py``) orchestrates session
|
||||
creation, destruction, and discovery.
|
||||
|
||||
Usage
|
||||
-----
|
||||
Install the Temporal extra first::
|
||||
|
||||
uv sync --extra temporal --extra daytona
|
||||
|
||||
Start a local Temporal server (requires the Temporal CLI)::
|
||||
|
||||
temporal server start-dev
|
||||
|
||||
In one terminal, start the worker::
|
||||
|
||||
python examples/sandbox/extensions/temporal_sandbox_agent.py worker
|
||||
|
||||
In another terminal, start the TUI::
|
||||
|
||||
python examples/sandbox/extensions/temporal_sandbox_agent.py run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os as _os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, SerializeAsAny, field_validator, model_serializer
|
||||
from temporalio import workflow
|
||||
from temporalio.client import Client
|
||||
from temporalio.contrib.openai_agents.workflow import ( # type: ignore[attr-defined]
|
||||
temporal_sandbox_client,
|
||||
)
|
||||
from temporalio.worker import Worker
|
||||
from temporalio.worker.workflow_sandbox import (
|
||||
SandboxedWorkflowRunner,
|
||||
SandboxRestrictions,
|
||||
)
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.agent import Agent
|
||||
from agents.extensions.sandbox import (
|
||||
DaytonaSandboxClientOptions,
|
||||
DaytonaSandboxSessionState,
|
||||
E2BSandboxClientOptions,
|
||||
E2BSandboxSessionState,
|
||||
)
|
||||
from agents.items import (
|
||||
MessageOutputItem,
|
||||
RunItem,
|
||||
ToolApprovalItem,
|
||||
ToolCallItem,
|
||||
TResponseInputItem,
|
||||
)
|
||||
from agents.lifecycle import RunHooksBase
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes import (
|
||||
DockerSandboxClientOptions,
|
||||
DockerSandboxSessionState,
|
||||
UnixLocalSandboxClientOptions,
|
||||
UnixLocalSandboxSessionState,
|
||||
)
|
||||
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
|
||||
from agents.sandbox.snapshot import SnapshotBase
|
||||
|
||||
# Allow sibling and repo-root imports.
|
||||
_THIS_DIR = _os.path.dirname(_os.path.abspath(__file__))
|
||||
_REPO_ROOT = _os.path.abspath(_os.path.join(_THIS_DIR, "..", "..", "..", ".."))
|
||||
for _p in (_THIS_DIR, _REPO_ROOT):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability # noqa: E402
|
||||
|
||||
|
||||
class SandboxBackend(str, Enum):
|
||||
DAYTONA = "daytona"
|
||||
DOCKER = "docker"
|
||||
E2B = "e2b"
|
||||
LOCAL = "local"
|
||||
|
||||
|
||||
DEFAULT_BACKEND = SandboxBackend.DAYTONA
|
||||
TASK_QUEUE = "sandbox-agent-queue"
|
||||
|
||||
|
||||
class _AlwaysSerializeType(BaseModel):
|
||||
"""Base that ensures the ``type`` discriminator survives ``exclude_unset`` round-trips."""
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
|
||||
data: dict[str, Any] = handler(self)
|
||||
data["type"] = self.type # type: ignore[attr-defined]
|
||||
return data
|
||||
|
||||
|
||||
class SwitchToLocalBackend(_AlwaysSerializeType):
|
||||
"""Switch target for the local unix sandbox backend."""
|
||||
|
||||
type: Literal["local"] = "local"
|
||||
workspace_root: str = "/workspace"
|
||||
|
||||
|
||||
class SwitchBackendSignal(BaseModel):
|
||||
"""Payload for the ``switch_backend`` signal."""
|
||||
|
||||
target: Literal["daytona", "docker", "e2b"] | SwitchToLocalBackend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow input / output types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _HasSnapshot(BaseModel):
|
||||
@field_validator("snapshot", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _parse_snapshot(cls, v: object) -> SnapshotBase | None:
|
||||
if v is None or isinstance(v, SnapshotBase):
|
||||
return v
|
||||
return SnapshotBase.parse(v)
|
||||
|
||||
|
||||
class WorkflowSnapshot(_HasSnapshot):
|
||||
"""Atomic snapshot of an agent workflow's forkable state."""
|
||||
|
||||
sandbox_session_state: (
|
||||
DaytonaSandboxSessionState
|
||||
| DockerSandboxSessionState
|
||||
| E2BSandboxSessionState
|
||||
| UnixLocalSandboxSessionState
|
||||
| None
|
||||
) = None
|
||||
snapshot: SerializeAsAny[SnapshotBase] | None = (
|
||||
None # serialized SnapshotBase for cross-backend creation
|
||||
)
|
||||
previous_response_id: str | None = None
|
||||
history: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
class AgentRequest(_HasSnapshot):
|
||||
messages: list[dict[str, Any]]
|
||||
cwd: str = ""
|
||||
backend: str = "daytona" # SandboxBackend value — determines client options
|
||||
sandbox_session_state: (
|
||||
DaytonaSandboxSessionState
|
||||
| DockerSandboxSessionState
|
||||
| E2BSandboxSessionState
|
||||
| UnixLocalSandboxSessionState
|
||||
| None
|
||||
) = None
|
||||
snapshot: SerializeAsAny[SnapshotBase] | None = (
|
||||
None # serialized SnapshotBase for cross-backend creation
|
||||
)
|
||||
previous_response_id: str | None = None
|
||||
history: list[dict[str, Any]] = [] # conversation history to seed (e.g. when forking)
|
||||
manifest: Manifest | None = None # per-session manifest override
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
"""Returned when the workflow is destroyed."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ToolCallRecord(BaseModel):
|
||||
"""A single tool call with its input and output for TUI display."""
|
||||
|
||||
tool_name: str
|
||||
description: str
|
||||
arguments_json: str
|
||||
output: str | None = None
|
||||
requires_approval: bool = False
|
||||
approved: bool | None = None
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
"""Structured response from chat() replacing the plain string."""
|
||||
|
||||
text: str | None = None
|
||||
tool_calls: list[ToolCallRecord] = []
|
||||
approval_request: ToolCallRecord | None = None
|
||||
|
||||
|
||||
class LiveToolCall(BaseModel):
|
||||
"""A tool call visible to the TUI during an active turn."""
|
||||
|
||||
call_id: str
|
||||
tool_name: str
|
||||
arguments: str
|
||||
status: str = "pending" # pending | running | completed
|
||||
output: str | None = None
|
||||
|
||||
|
||||
class TurnState(BaseModel):
|
||||
"""Everything the TUI needs — returned by a single query during polling."""
|
||||
|
||||
# idle | thinking | awaiting_approval | complete
|
||||
status: str = "idle"
|
||||
tool_calls: list[LiveToolCall] = []
|
||||
response_text: str | None = None
|
||||
approval_request: ToolCallRecord | None = None
|
||||
turn_id: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_approval_item(item: ToolApprovalItem) -> str:
|
||||
"""Return a human-readable summary of a tool approval request."""
|
||||
raw = item.raw_item
|
||||
name = getattr(raw, "name", None) or item.tool_name or "unknown"
|
||||
|
||||
# Try to extract arguments for shell commands
|
||||
args_str = getattr(raw, "arguments", None)
|
||||
if args_str and isinstance(args_str, str):
|
||||
try:
|
||||
parsed = json.loads(args_str)
|
||||
if name == "shell" and "commands" in parsed:
|
||||
cmds = parsed["commands"]
|
||||
return f"shell: {'; '.join(cmds)}"
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return f"{name}: {args_str or '(no args)'}"
|
||||
|
||||
|
||||
def _extract_text_from_items(items: list[RunItem]) -> str | None:
|
||||
"""Pull the last assistant text from generated run items."""
|
||||
for item in reversed(items):
|
||||
if isinstance(item, MessageOutputItem):
|
||||
raw = item.raw_item
|
||||
content = getattr(raw, "content", [])
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
text = getattr(block, "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _tool_call_records_from_items(items: list[RunItem]) -> list[ToolCallRecord]:
|
||||
"""Build ToolCallRecord list from generated RunItems."""
|
||||
records: list[ToolCallRecord] = []
|
||||
for item in items:
|
||||
if isinstance(item, ToolCallItem):
|
||||
raw = item.raw_item
|
||||
name = getattr(raw, "name", None) or "unknown"
|
||||
args = getattr(raw, "arguments", "{}")
|
||||
records.append(
|
||||
ToolCallRecord(
|
||||
tool_name=name,
|
||||
description=f"{name}: {args}",
|
||||
arguments_json=args if isinstance(args, str) else json.dumps(args),
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow definition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _LiveStateHooks(RunHooksBase[Any, Agent[Any]]):
|
||||
"""RunHooks that update workflow-queryable state for live TUI polling."""
|
||||
|
||||
def __init__(self, wf: AgentWorkflow) -> None:
|
||||
self._wf = wf
|
||||
|
||||
async def on_llm_end(self, context, agent, response):
|
||||
"""Extract tool calls from the model response and register them."""
|
||||
for item in response.output:
|
||||
call_id = getattr(item, "call_id", None)
|
||||
if not call_id:
|
||||
continue
|
||||
# Standard function calls have name + arguments
|
||||
name = getattr(item, "name", None)
|
||||
if name:
|
||||
self._wf._live_tool_calls.append(
|
||||
LiveToolCall(
|
||||
call_id=call_id,
|
||||
tool_name=name,
|
||||
arguments=getattr(item, "arguments", None) or "{}",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
continue
|
||||
# Shell tool calls have action.commands / action.command
|
||||
action = getattr(item, "action", None)
|
||||
if action:
|
||||
cmds = getattr(action, "commands", None) or getattr(action, "command", None)
|
||||
if isinstance(cmds, list):
|
||||
args = json.dumps({"commands": cmds})
|
||||
elif isinstance(cmds, str):
|
||||
args = json.dumps({"command": cmds})
|
||||
else:
|
||||
args = "{}"
|
||||
tool_name = getattr(item, "type", None) or "shell"
|
||||
self._wf._live_tool_calls.append(
|
||||
LiveToolCall(
|
||||
call_id=call_id,
|
||||
tool_name=tool_name,
|
||||
arguments=args,
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
|
||||
async def on_tool_start(self, context, agent, tool):
|
||||
# Match first pending tool call (tools execute in order)
|
||||
for tc in self._wf._live_tool_calls:
|
||||
if tc.status == "pending":
|
||||
tc.status = "running"
|
||||
break
|
||||
|
||||
async def on_tool_end(self, context, agent, tool, result):
|
||||
# Match first running tool call
|
||||
for tc in self._wf._live_tool_calls:
|
||||
if tc.status == "running":
|
||||
tc.status = "completed"
|
||||
tc.output = result[:4000] if result else None
|
||||
break
|
||||
|
||||
|
||||
@workflow.defn
|
||||
class AgentWorkflow:
|
||||
"""A long-lived conversational agent workflow.
|
||||
|
||||
The workflow persists indefinitely in Temporal, idling between TUI
|
||||
sessions. It only terminates when explicitly destroyed via the
|
||||
``destroy`` signal (sent by the session manager).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending_messages: list[str] = []
|
||||
self._done = False
|
||||
self._conversation_history: list[dict[str, Any]] = []
|
||||
self._sandbox_session_state: (
|
||||
DaytonaSandboxSessionState
|
||||
| DockerSandboxSessionState
|
||||
| E2BSandboxSessionState
|
||||
| UnixLocalSandboxSessionState
|
||||
| None
|
||||
) = None
|
||||
self._previous_response_id: str | None = None
|
||||
self._paused: bool = False
|
||||
self._pause_requested = False
|
||||
self._turn_tool_calls: list[ToolCallRecord] = []
|
||||
self._manifest_override: Manifest | None = None
|
||||
self._backend: SandboxBackend = DEFAULT_BACKEND
|
||||
self._snapshot: SnapshotBase | None = None
|
||||
self._live_tool_calls: list[LiveToolCall] = []
|
||||
# Turn state — queried by the TUI polling loop
|
||||
self._turn_status: str = "idle"
|
||||
self._turn_id: int = 0
|
||||
self._last_response_text: str | None = None
|
||||
self._pending_approval: ToolCallRecord | None = None
|
||||
|
||||
@workflow.query
|
||||
def is_paused(self) -> bool:
|
||||
return self._paused
|
||||
|
||||
@workflow.signal
|
||||
async def send_message(self, msg: str) -> None:
|
||||
"""Enqueue a user message. The TUI drives everything via get_turn_state polling."""
|
||||
self._pending_messages.append(msg)
|
||||
self._conversation_history.append({"role": "user", "content": msg})
|
||||
|
||||
@workflow.query
|
||||
def get_history(self) -> list[dict[str, Any]]:
|
||||
"""Return conversation history for TUI replay on reconnect."""
|
||||
return self._conversation_history
|
||||
|
||||
@workflow.query
|
||||
def get_snapshot_id(self) -> str | None:
|
||||
"""Return just the current snapshot ID (lightweight)."""
|
||||
if self._sandbox_session_state:
|
||||
return self._sandbox_session_state.snapshot.id
|
||||
return None
|
||||
|
||||
@workflow.query
|
||||
def get_snapshot(self) -> WorkflowSnapshot:
|
||||
"""Return an atomic snapshot of run state and conversation history."""
|
||||
# Prefer the live session snapshot, but fall back to self._snapshot
|
||||
# so workspace state survives a backend switch (which clears
|
||||
# _sandbox_session_state) until the next turn recreates a session.
|
||||
snapshot = self._snapshot
|
||||
if self._sandbox_session_state:
|
||||
snapshot = self._sandbox_session_state.snapshot
|
||||
return WorkflowSnapshot(
|
||||
sandbox_session_state=self._sandbox_session_state,
|
||||
snapshot=snapshot,
|
||||
previous_response_id=self._previous_response_id,
|
||||
history=self._conversation_history,
|
||||
)
|
||||
|
||||
@workflow.query
|
||||
def get_turn_state(self) -> TurnState:
|
||||
"""Single query that returns everything the TUI needs."""
|
||||
return TurnState(
|
||||
status=self._turn_status,
|
||||
tool_calls=list(self._live_tool_calls),
|
||||
response_text=self._last_response_text,
|
||||
approval_request=self._pending_approval,
|
||||
turn_id=self._turn_id,
|
||||
)
|
||||
|
||||
@workflow.update
|
||||
async def pause(self) -> None:
|
||||
"""Request the workflow to pause."""
|
||||
if self._paused:
|
||||
return
|
||||
self._pause_requested = True
|
||||
await workflow.wait_condition(lambda: self._paused)
|
||||
|
||||
@workflow.update
|
||||
async def switch_backend(self, args: SwitchBackendSignal) -> None:
|
||||
"""Switch to a different sandbox backend for subsequent turns.
|
||||
|
||||
Clears the backend-specific session state so the next turn creates a
|
||||
fresh session on the new backend. The portable snapshot is preserved
|
||||
so the workspace filesystem can be carried over.
|
||||
"""
|
||||
match args.target:
|
||||
case "daytona":
|
||||
self._backend = SandboxBackend.DAYTONA
|
||||
self._manifest_override = Manifest(root="/home/daytona/workspace")
|
||||
case "docker":
|
||||
self._backend = SandboxBackend.DOCKER
|
||||
self._manifest_override = Manifest(root="/workspace")
|
||||
case "e2b":
|
||||
self._backend = SandboxBackend.E2B
|
||||
self._manifest_override = Manifest() # E2B resolves relative to sandbox home
|
||||
case SwitchToLocalBackend(workspace_root=root):
|
||||
self._backend = SandboxBackend.LOCAL
|
||||
self._manifest_override = Manifest(root=root)
|
||||
self._sandbox_session_state = None
|
||||
|
||||
@workflow.signal
|
||||
async def destroy(self) -> None:
|
||||
"""Terminate the workflow permanently."""
|
||||
self._done = True
|
||||
|
||||
def _resolve_sandbox_options(
|
||||
self,
|
||||
) -> (
|
||||
DaytonaSandboxClientOptions
|
||||
| DockerSandboxClientOptions
|
||||
| E2BSandboxClientOptions
|
||||
| UnixLocalSandboxClientOptions
|
||||
):
|
||||
match self._backend:
|
||||
case SandboxBackend.DAYTONA:
|
||||
return DaytonaSandboxClientOptions(pause_on_exit=False)
|
||||
case SandboxBackend.DOCKER:
|
||||
return DockerSandboxClientOptions(image="python:3.14")
|
||||
case SandboxBackend.E2B:
|
||||
return E2BSandboxClientOptions(sandbox_type="e2b")
|
||||
case SandboxBackend.LOCAL:
|
||||
return UnixLocalSandboxClientOptions()
|
||||
|
||||
def _resolve_manifest(self) -> Manifest:
|
||||
match self._backend:
|
||||
case SandboxBackend.DAYTONA:
|
||||
return Manifest(root="/home/daytona/workspace")
|
||||
case SandboxBackend.DOCKER:
|
||||
return Manifest(root="/workspace")
|
||||
case SandboxBackend.E2B:
|
||||
return Manifest() # E2B resolves workspace root relative to the sandbox home
|
||||
case SandboxBackend.LOCAL:
|
||||
return Manifest(root="/workspace")
|
||||
|
||||
@workflow.run
|
||||
async def run(self, request: AgentRequest) -> AgentResponse:
|
||||
self._backend = SandboxBackend(request.backend)
|
||||
self._snapshot = request.snapshot
|
||||
if request.history:
|
||||
self._conversation_history = list(request.history)
|
||||
if request.sandbox_session_state:
|
||||
self._sandbox_session_state = request.sandbox_session_state
|
||||
if request.previous_response_id:
|
||||
self._previous_response_id = request.previous_response_id
|
||||
|
||||
self._manifest_override = request.manifest
|
||||
|
||||
while not self._done:
|
||||
await workflow.wait_condition(
|
||||
lambda: (len(self._pending_messages) > 0 or self._pause_requested or self._done),
|
||||
)
|
||||
|
||||
if self._pause_requested:
|
||||
# Let the caller (e.g. SessionManagerWorkflow.fork_session) know
|
||||
# no turn is in progress so it can safely snapshot state.
|
||||
self._paused = True
|
||||
self._pause_requested = False
|
||||
await workflow.wait_condition(lambda: len(self._pending_messages) > 0 or self._done)
|
||||
self._paused = False
|
||||
|
||||
if self._done:
|
||||
break
|
||||
|
||||
user_messages = list(self._pending_messages)
|
||||
self._pending_messages.clear()
|
||||
|
||||
self._turn_id += 1
|
||||
self._turn_status = "thinking"
|
||||
self._live_tool_calls = []
|
||||
self._pending_approval = None
|
||||
self._last_response_text = None
|
||||
|
||||
try:
|
||||
manifest = self._manifest_override or self._resolve_manifest()
|
||||
agent = self._build_agent(manifest)
|
||||
await self._run_turn(agent, user_messages)
|
||||
self._last_response_text = self._last_text
|
||||
if self._last_text:
|
||||
self._conversation_history.append(
|
||||
{"role": "assistant", "content": self._last_text}
|
||||
)
|
||||
except Exception as e:
|
||||
self._last_response_text = f"Error: {e}"
|
||||
finally:
|
||||
self._turn_status = "complete"
|
||||
|
||||
return AgentResponse()
|
||||
|
||||
def _build_agent(self, manifest: Manifest, model: str = "gpt-5.4") -> SandboxAgent:
|
||||
"""Construct the SandboxAgent used by the workflow."""
|
||||
return SandboxAgent(
|
||||
name="Temporal Sandbox Agent",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You are a helpful coding assistant. Inspect the workspace and answer "
|
||||
"questions. Use the shell tool to run commands. "
|
||||
"Do not invent files or statuses that are not present in the workspace. "
|
||||
"Cite the file names you inspected."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="auto"),
|
||||
)
|
||||
|
||||
async def _run_turn(
|
||||
self,
|
||||
agent: SandboxAgent,
|
||||
user_messages: list[str],
|
||||
) -> None:
|
||||
self._turn_tool_calls = []
|
||||
self._last_text: str | None = None
|
||||
|
||||
hooks = _LiveStateHooks(self)
|
||||
|
||||
# Always pass fresh input — previous_response_id gives the API
|
||||
# conversation context. Sandbox session state is carried via
|
||||
# run_config.sandbox.session_state to preserve the sandbox across turns.
|
||||
if len(user_messages) == 1:
|
||||
input_arg: str | list[TResponseInputItem] = user_messages[0]
|
||||
else:
|
||||
input_arg = [{"role": "user", "content": m} for m in user_messages]
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(
|
||||
client=temporal_sandbox_client(self._backend.value),
|
||||
options=self._resolve_sandbox_options(),
|
||||
# Restore sandbox session state from the previous turn if available.
|
||||
session_state=self._sandbox_session_state,
|
||||
snapshot=self._snapshot,
|
||||
),
|
||||
workflow_name="Temporal Sandbox workflow",
|
||||
)
|
||||
|
||||
# Run the agent -- loops internally handling tool calls
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
input_arg,
|
||||
run_config=run_config,
|
||||
hooks=hooks,
|
||||
previous_response_id=self._previous_response_id,
|
||||
)
|
||||
|
||||
# Extract results
|
||||
self._turn_tool_calls.extend(_tool_call_records_from_items(result.new_items))
|
||||
self._last_text = _extract_text_from_items(result.new_items)
|
||||
|
||||
# Track response ID for conversation continuity and save state
|
||||
# to preserve sandbox session across turns.
|
||||
self._previous_response_id = result.last_response_id
|
||||
|
||||
# Persist sandbox session state for the next turn.
|
||||
try:
|
||||
state = result.to_state()
|
||||
sandbox_data = state.to_json().get("sandbox", {})
|
||||
session_state_data = sandbox_data.get("session_state")
|
||||
if session_state_data:
|
||||
self._sandbox_session_state = cast(
|
||||
DaytonaSandboxSessionState | UnixLocalSandboxSessionState,
|
||||
SandboxSessionState.parse(session_state_data),
|
||||
)
|
||||
# Keep the portable snapshot up to date so it can seed a
|
||||
# fresh session after a backend switch.
|
||||
self._snapshot = self._sandbox_session_state.snapshot
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def run_worker() -> None:
|
||||
# Imported here to avoid unnecessary passthroughs in the workflow sandbox.
|
||||
import docker # type: ignore[import-untyped]
|
||||
from _worker_setup import print_backend_warnings # type: ignore[import-not-found]
|
||||
from temporal_session_manager import ( # type: ignore[import-not-found]
|
||||
SessionManagerWorkflow,
|
||||
pause_workflow,
|
||||
query_workflow_snapshot,
|
||||
switch_workflow_backend,
|
||||
)
|
||||
from temporalio.contrib.openai_agents import ( # type: ignore[attr-defined]
|
||||
ModelActivityParameters,
|
||||
OpenAIAgentsPlugin,
|
||||
SandboxClientProvider,
|
||||
)
|
||||
|
||||
from agents.extensions.sandbox import DaytonaSandboxClient, E2BSandboxClient
|
||||
from agents.sandbox.sandboxes import DockerSandboxClient, UnixLocalSandboxClient
|
||||
|
||||
sandbox_clients: list[SandboxClientProvider] = [
|
||||
SandboxClientProvider("local", UnixLocalSandboxClient()),
|
||||
]
|
||||
if _os.environ.get("DAYTONA_API_KEY"):
|
||||
sandbox_clients.append(SandboxClientProvider("daytona", DaytonaSandboxClient()))
|
||||
if _os.environ.get("E2B_API_KEY"):
|
||||
sandbox_clients.append(SandboxClientProvider("e2b", E2BSandboxClient()))
|
||||
try:
|
||||
sandbox_clients.append(
|
||||
SandboxClientProvider("docker", DockerSandboxClient(docker.from_env()))
|
||||
)
|
||||
except docker.errors.DockerException:
|
||||
pass
|
||||
|
||||
plugin = OpenAIAgentsPlugin( # type: ignore[call-arg]
|
||||
model_params=ModelActivityParameters(
|
||||
start_to_close_timeout=timedelta(seconds=120),
|
||||
),
|
||||
sandbox_clients=sandbox_clients,
|
||||
)
|
||||
|
||||
temporal_client = await Client.connect("localhost:7233", plugins=[plugin])
|
||||
|
||||
worker = Worker(
|
||||
temporal_client,
|
||||
task_queue=TASK_QUEUE,
|
||||
workflows=[AgentWorkflow, SessionManagerWorkflow],
|
||||
activities=[pause_workflow, query_workflow_snapshot, switch_workflow_backend],
|
||||
workflow_runner=SandboxedWorkflowRunner(
|
||||
restrictions=SandboxRestrictions.default.with_passthrough_modules(
|
||||
"pydantic_core",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
print_backend_warnings({p.name for p in sandbox_clients})
|
||||
print(f"Worker started on task queue '{TASK_QUEUE}'. Press Ctrl-C to stop.")
|
||||
await worker.run()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entrypoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def run_conversation() -> None:
|
||||
"""Start the TUI -- sessions are managed entirely via Temporal."""
|
||||
from temporal_sandbox_tui import ConversationApp # type: ignore[import-not-found]
|
||||
|
||||
app = ConversationApp(
|
||||
workflow_cls=AgentWorkflow,
|
||||
task_queue=TASK_QUEUE,
|
||||
cwd=str(Path.cwd()),
|
||||
)
|
||||
await app.run_async()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the Sandbox agent as a multi-turn Temporal workflow."
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("worker", help="Start the Temporal worker process.")
|
||||
sub.add_parser("run", help="Start an interactive agent conversation.")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
if args.command == "worker":
|
||||
asyncio.run(run_worker())
|
||||
else:
|
||||
asyncio.run(run_conversation())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,406 @@
|
||||
# mypy: ignore-errors
|
||||
# standalone example with sys.path sibling imports that mypy cannot follow
|
||||
"""Temporal session manager workflow.
|
||||
|
||||
A long-lived singleton workflow that acts as the sole orchestrator for agent
|
||||
session lifecycles. It starts and stops agent workflows, and maintains a
|
||||
registry of active sessions so that TUI clients can list, resume, rename,
|
||||
and destroy sessions without any filesystem persistence.
|
||||
|
||||
The manager is started once (well-known workflow ID ``session-manager``) and
|
||||
lives forever. All lifecycle operations — create, destroy, rename, fork — go
|
||||
through the manager so the registry is always consistent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from temporalio import activity, workflow
|
||||
from temporalio.exceptions import ApplicationError
|
||||
from temporalio.workflow import ParentClosePolicy
|
||||
|
||||
with workflow.unsafe.imports_passed_through():
|
||||
from pydantic import BaseModel, field_validator, model_serializer
|
||||
from temporal_sandbox_agent import ( # type: ignore[import-not-found]
|
||||
TASK_QUEUE,
|
||||
AgentRequest,
|
||||
AgentWorkflow,
|
||||
SwitchBackendSignal,
|
||||
SwitchToLocalBackend,
|
||||
WorkflowSnapshot,
|
||||
)
|
||||
from temporalio.client import Client
|
||||
from temporalio.contrib.openai_agents import OpenAIAgentsPlugin
|
||||
from temporalio.contrib.pydantic import pydantic_data_converter
|
||||
|
||||
from agents import trace
|
||||
from agents.sandbox import Manifest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MANAGER_WORKFLOW_ID = "session-manager"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DaytonaBackendConfig(BaseModel):
|
||||
type: Literal["daytona"] = "daytona"
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
|
||||
data: dict[str, Any] = handler(self)
|
||||
data["type"] = self.type
|
||||
return data
|
||||
|
||||
|
||||
class DockerBackendConfig(BaseModel):
|
||||
type: Literal["docker"] = "docker"
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
|
||||
data: dict[str, Any] = handler(self)
|
||||
data["type"] = self.type
|
||||
return data
|
||||
|
||||
|
||||
class E2BBackendConfig(BaseModel):
|
||||
type: Literal["e2b"] = "e2b"
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
|
||||
data: dict[str, Any] = handler(self)
|
||||
data["type"] = self.type
|
||||
return data
|
||||
|
||||
|
||||
class LocalBackendConfig(BaseModel):
|
||||
type: Literal["local"] = "local"
|
||||
workspace_root: Path | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize_always_include_type(self, handler: Any) -> dict[str, Any]:
|
||||
data: dict[str, Any] = handler(self)
|
||||
data["type"] = self.type
|
||||
return data
|
||||
|
||||
@field_validator("workspace_root")
|
||||
@classmethod
|
||||
def _must_be_absolute(cls, v: Path | None) -> Path | None:
|
||||
if v is not None and not v.is_absolute():
|
||||
raise ValueError("workspace_root must be an absolute path")
|
||||
return v
|
||||
|
||||
|
||||
BackendConfig = DaytonaBackendConfig | DockerBackendConfig | E2BBackendConfig | LocalBackendConfig
|
||||
|
||||
|
||||
class SessionInfo(BaseModel):
|
||||
workflow_id: str
|
||||
title: str
|
||||
created_at: datetime
|
||||
cwd: str = ""
|
||||
backend: BackendConfig = DaytonaBackendConfig()
|
||||
parent_workflow_id: str | None = None
|
||||
fork_count: int = 0
|
||||
snapshot_id: str | None = None
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
cwd: str
|
||||
manifest: Manifest | None = None
|
||||
backend: BackendConfig = DaytonaBackendConfig()
|
||||
|
||||
|
||||
class RenameRequest(BaseModel):
|
||||
workflow_id: str
|
||||
title: str
|
||||
|
||||
|
||||
class ForkSessionRequest(BaseModel):
|
||||
source_workflow_id: str
|
||||
title: str | None = None # defaults to "{original title} (fork #N)"
|
||||
target_backend: BackendConfig | None = None
|
||||
|
||||
|
||||
class SwitchBackendRequest(BaseModel):
|
||||
source_workflow_id: str
|
||||
target_backend: BackendConfig
|
||||
|
||||
|
||||
class _SwitchWorkflowBackendArgs(BaseModel):
|
||||
"""Activity args for switch_workflow_backend."""
|
||||
|
||||
workflow_id: str
|
||||
signal: SwitchBackendSignal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _default_manifest(
|
||||
backend: BackendConfig,
|
||||
) -> Manifest:
|
||||
"""Return the default workspace manifest for the given backend config."""
|
||||
if isinstance(backend, DaytonaBackendConfig):
|
||||
return Manifest(root="/home/daytona/workspace")
|
||||
if isinstance(backend, DockerBackendConfig):
|
||||
return Manifest(root="/workspace")
|
||||
if isinstance(backend, E2BBackendConfig):
|
||||
return Manifest() # E2B resolves workspace root relative to the sandbox home
|
||||
root = str(backend.workspace_root) if backend.workspace_root else "/workspace"
|
||||
return Manifest(root=root)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def pause_workflow(workflow_id: str) -> None:
|
||||
"""Pause the agent workflow and wait for its session to fully stop."""
|
||||
client = await Client.connect("localhost:7233", data_converter=pydantic_data_converter)
|
||||
handle = client.get_workflow_handle(workflow_id)
|
||||
await handle.execute_update(AgentWorkflow.pause)
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def switch_workflow_backend(args: _SwitchWorkflowBackendArgs) -> None:
|
||||
"""Switch the agent workflow's backend and wait for it to take effect."""
|
||||
client = await Client.connect("localhost:7233", data_converter=pydantic_data_converter)
|
||||
handle = client.get_workflow_handle(args.workflow_id)
|
||||
await handle.execute_update(AgentWorkflow.switch_backend, args.signal)
|
||||
|
||||
|
||||
@activity.defn
|
||||
async def query_workflow_snapshot(workflow_id: str) -> WorkflowSnapshot:
|
||||
"""Query the target workflow for its run state and conversation history."""
|
||||
client = await Client.connect("localhost:7233", data_converter=pydantic_data_converter)
|
||||
handle = client.get_workflow_handle(workflow_id)
|
||||
return await handle.query(AgentWorkflow.get_snapshot)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@workflow.defn
|
||||
class SessionManagerWorkflow:
|
||||
"""Registry and orchestrator for agent sessions.
|
||||
|
||||
* ``create_session`` — starts a new agent child workflow and registers it.
|
||||
* ``destroy_session`` — signals the agent workflow to terminate and
|
||||
removes it from the registry.
|
||||
* ``list_sessions`` — query returning all active sessions.
|
||||
* ``rename_session`` — signal to update a session title.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, SessionInfo] = {}
|
||||
self._shutdown = False
|
||||
|
||||
# -- Main loop (lives forever) -----------------------------------------
|
||||
|
||||
@workflow.run
|
||||
async def run(self) -> None:
|
||||
await workflow.wait_condition(lambda: self._shutdown)
|
||||
|
||||
# -- Lifecycle: create & destroy (updates for request-response) ---------
|
||||
|
||||
@workflow.update
|
||||
async def create_session(self, request: CreateSessionRequest) -> str:
|
||||
"""Start a new agent workflow and register it. Returns the workflow ID."""
|
||||
workflow_id = f"sandbox-agent-{workflow.uuid4()}"
|
||||
|
||||
manifest = request.manifest
|
||||
if manifest is None:
|
||||
manifest = _default_manifest(request.backend)
|
||||
|
||||
with OpenAIAgentsPlugin().tracing_context():
|
||||
with trace("Temporal Sandbox Sandbox Agent"):
|
||||
await workflow.start_child_workflow(
|
||||
AgentWorkflow.run,
|
||||
AgentRequest(
|
||||
messages=[],
|
||||
cwd=request.cwd,
|
||||
backend=request.backend.type,
|
||||
history=[],
|
||||
manifest=manifest,
|
||||
),
|
||||
id=workflow_id,
|
||||
task_queue=TASK_QUEUE,
|
||||
parent_close_policy=ParentClosePolicy.ABANDON,
|
||||
)
|
||||
self._sessions[workflow_id] = SessionInfo(
|
||||
workflow_id=workflow_id,
|
||||
title=f"Session {workflow_id[-8:]}",
|
||||
created_at=workflow.now(),
|
||||
cwd=request.cwd,
|
||||
backend=request.backend,
|
||||
)
|
||||
return workflow_id
|
||||
|
||||
@workflow.update
|
||||
async def fork_session(self, request: ForkSessionRequest) -> str:
|
||||
"""Fork an existing session into a new workflow with identical state.
|
||||
|
||||
Pauses the source workflow, queries its RunState and conversation
|
||||
history, then starts a new child workflow seeded with that state.
|
||||
When ``target_backend`` differs from the source, the sandbox session
|
||||
state is not carried over (it is backend-specific), but the portable
|
||||
snapshot is extracted so the new backend can create a fresh session
|
||||
from the same workspace filesystem state.
|
||||
"""
|
||||
source = self._sessions.get(request.source_workflow_id)
|
||||
if source is None:
|
||||
raise ApplicationError(f"Source session {request.source_workflow_id} not found")
|
||||
|
||||
# Pause the source workflow so its session stops naturally
|
||||
await workflow.execute_activity(
|
||||
pause_workflow,
|
||||
request.source_workflow_id,
|
||||
start_to_close_timeout=timedelta(minutes=11),
|
||||
)
|
||||
|
||||
# Fetch the source workflow's state via activity
|
||||
workflow_snapshot: WorkflowSnapshot = await workflow.execute_activity(
|
||||
query_workflow_snapshot,
|
||||
request.source_workflow_id,
|
||||
start_to_close_timeout=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
target_config = (
|
||||
request.target_backend if request.target_backend is not None else source.backend
|
||||
)
|
||||
cross_backend = target_config.type != source.backend.type
|
||||
|
||||
# Determine fork title
|
||||
source.fork_count += 1
|
||||
if cross_backend:
|
||||
title = request.title or f"{source.title} [{target_config.type}]"
|
||||
else:
|
||||
title = request.title or f"{source.title} (fork #{source.fork_count})"
|
||||
|
||||
# Always pass the portable snapshot so the forked session can seed
|
||||
# its workspace. Never carry session_state — a fork creates an
|
||||
# independent session seeded from the snapshot, not a resume of the
|
||||
# source session.
|
||||
snapshot = workflow_snapshot.snapshot
|
||||
|
||||
manifest = _default_manifest(target_config)
|
||||
|
||||
# Start the forked workflow with the source's run state and history
|
||||
workflow_id = f"sandbox-agent-{workflow.uuid4()}"
|
||||
await workflow.start_child_workflow(
|
||||
AgentWorkflow.run,
|
||||
AgentRequest(
|
||||
messages=[],
|
||||
cwd=source.cwd,
|
||||
backend=target_config.type,
|
||||
sandbox_session_state=None,
|
||||
snapshot=snapshot,
|
||||
previous_response_id=workflow_snapshot.previous_response_id,
|
||||
history=workflow_snapshot.history,
|
||||
manifest=manifest,
|
||||
),
|
||||
id=workflow_id,
|
||||
task_queue=TASK_QUEUE,
|
||||
parent_close_policy=ParentClosePolicy.ABANDON,
|
||||
)
|
||||
|
||||
self._sessions[workflow_id] = SessionInfo(
|
||||
workflow_id=workflow_id,
|
||||
title=title,
|
||||
created_at=workflow.now(),
|
||||
cwd=source.cwd,
|
||||
backend=target_config,
|
||||
parent_workflow_id=request.source_workflow_id,
|
||||
snapshot_id=workflow_snapshot.sandbox_session_state.snapshot.id
|
||||
if workflow_snapshot.sandbox_session_state
|
||||
else None,
|
||||
)
|
||||
return workflow_id
|
||||
|
||||
@workflow.update
|
||||
async def switch_backend(self, request: SwitchBackendRequest) -> str:
|
||||
"""Switch a session to a different sandbox backend in-place.
|
||||
|
||||
Signals the agent workflow to change its backend for subsequent turns.
|
||||
The workflow stays the same — no fork, no new child workflow. The
|
||||
portable snapshot is preserved so the workspace can be carried over;
|
||||
the backend-specific session state is cleared by the agent workflow.
|
||||
"""
|
||||
source = self._sessions.get(request.source_workflow_id)
|
||||
if source is None:
|
||||
raise ApplicationError(f"Session {request.source_workflow_id} not found")
|
||||
|
||||
if isinstance(request.target_backend, LocalBackendConfig):
|
||||
target: Literal["daytona", "docker", "e2b"] | SwitchToLocalBackend = (
|
||||
SwitchToLocalBackend(
|
||||
workspace_root=str(request.target_backend.workspace_root)
|
||||
if request.target_backend.workspace_root
|
||||
else "/workspace",
|
||||
)
|
||||
)
|
||||
else:
|
||||
target = request.target_backend.type
|
||||
await workflow.execute_activity(
|
||||
switch_workflow_backend,
|
||||
_SwitchWorkflowBackendArgs(
|
||||
workflow_id=request.source_workflow_id,
|
||||
signal=SwitchBackendSignal(target=target),
|
||||
),
|
||||
start_to_close_timeout=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
source.backend = request.target_backend
|
||||
return request.source_workflow_id
|
||||
|
||||
@workflow.update
|
||||
async def destroy_session(self, workflow_id: str) -> None:
|
||||
"""Signal the agent workflow to destroy and remove it from the registry."""
|
||||
handle = workflow.get_external_workflow_handle(workflow_id)
|
||||
await handle.signal(AgentWorkflow.destroy)
|
||||
self._sessions.pop(workflow_id, None)
|
||||
|
||||
# -- Metadata: queries and signals --------------------------------------
|
||||
|
||||
@workflow.query
|
||||
def list_sessions(self) -> list[SessionInfo]:
|
||||
"""Return all active sessions, newest first."""
|
||||
return sorted(
|
||||
self._sessions.values(),
|
||||
key=lambda s: s.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@workflow.signal
|
||||
async def rename_session(self, request: RenameRequest) -> None:
|
||||
"""Update the title of an existing session."""
|
||||
if request.workflow_id in self._sessions:
|
||||
self._sessions[request.workflow_id].title = request.title
|
||||
|
||||
@workflow.signal
|
||||
async def update_snapshot_id(self, request: RenameRequest) -> None:
|
||||
"""Update the cached snapshot_id for a session.
|
||||
|
||||
Reuses RenameRequest where ``title`` carries the snapshot ID.
|
||||
"""
|
||||
if request.workflow_id in self._sessions:
|
||||
self._sessions[request.workflow_id].snapshot_id = request.title
|
||||
|
||||
@workflow.signal
|
||||
async def shutdown(self) -> None:
|
||||
"""Terminate the manager workflow (rarely needed)."""
|
||||
self._shutdown = True
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Minimal Vercel-backed sandbox example for manual validation.
|
||||
|
||||
This mirrors the other cloud extension examples: it creates a tiny workspace,
|
||||
verifies stop/resume persistence, then asks a sandboxed agent to inspect the
|
||||
workspace through one shell tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.models.openai_provider import OpenAIProvider
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.session import BaseSandboxSession
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
try:
|
||||
from agents.extensions.sandbox import VercelSandboxClient, VercelSandboxClientOptions
|
||||
except Exception as exc: # pragma: no cover - import path depends on optional extras
|
||||
raise SystemExit(
|
||||
"Vercel sandbox examples require the optional repo extra.\n"
|
||||
"Install it with: uv sync --extra vercel"
|
||||
) from exc
|
||||
|
||||
|
||||
DEFAULT_QUESTION = "Summarize this cloud sandbox workspace in 2 sentences."
|
||||
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
|
||||
SNAPSHOT_CHECK_CONTENT = "vercel snapshot round-trip ok\n"
|
||||
LIVE_RESUME_CHECK_PATH = Path("live-resume-check.txt")
|
||||
LIVE_RESUME_CHECK_CONTENT = "vercel live resume ok\n"
|
||||
EXPOSED_PORT = 3000
|
||||
PORT_CHECK_CONTENT = "<h1>vercel exposed port ok</h1>\n"
|
||||
PORT_CHECK_NODE_SERVER_PATH = Path(".port-check-server.js")
|
||||
PORT_CHECK_NODE_SERVER_CONTENT = f"""\
|
||||
const http = require("node:http");
|
||||
|
||||
http
|
||||
.createServer((_request, response) => {{
|
||||
response.writeHead(200, {{"Content-Type": "text/html; charset=utf-8"}});
|
||||
response.end({json.dumps(PORT_CHECK_CONTENT)});
|
||||
}})
|
||||
.listen({EXPOSED_PORT}, "0.0.0.0");
|
||||
"""
|
||||
PORT_CHECK_PYTHON_SERVER_PATH = Path(".port-check-server.py")
|
||||
PORT_CHECK_PYTHON_SERVER_CONTENT = f"""\
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
body = {PORT_CHECK_CONTENT!r}.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
ThreadingHTTPServer(("0.0.0.0", {EXPOSED_PORT}), Handler).serve_forever()
|
||||
"""
|
||||
|
||||
|
||||
def _build_manifest() -> Manifest:
|
||||
return text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Vercel Demo Workspace\n\n"
|
||||
"This workspace exists to validate the Vercel sandbox backend manually.\n"
|
||||
),
|
||||
"handoff.md": (
|
||||
"# Handoff\n\n"
|
||||
"- Customer: Northwind Traders.\n"
|
||||
"- Goal: validate Vercel sandbox exec and persistence flows.\n"
|
||||
"- Current status: non-PTY backend slice is wired and under test.\n"
|
||||
),
|
||||
"todo.md": (
|
||||
"# Todo\n\n"
|
||||
"1. Inspect the workspace files.\n"
|
||||
"2. Summarize the current status in two sentences.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _read_text(session: BaseSandboxSession, path: Path) -> str:
|
||||
data = await session.read(path)
|
||||
text = cast(str | bytes, data.read())
|
||||
if isinstance(text, bytes):
|
||||
return text.decode("utf-8")
|
||||
return text
|
||||
|
||||
|
||||
def _require_env(name: str) -> None:
|
||||
if os.environ.get(name):
|
||||
return
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
def _require_vercel_credentials() -> None:
|
||||
if os.environ.get("VERCEL_OIDC_TOKEN"):
|
||||
return
|
||||
if (
|
||||
os.environ.get("VERCEL_TOKEN")
|
||||
and os.environ.get("VERCEL_PROJECT_ID")
|
||||
and os.environ.get("VERCEL_TEAM_ID")
|
||||
):
|
||||
return
|
||||
raise SystemExit(
|
||||
"Vercel credentials are required. Set VERCEL_OIDC_TOKEN, or set "
|
||||
"VERCEL_TOKEN together with VERCEL_PROJECT_ID and VERCEL_TEAM_ID."
|
||||
)
|
||||
|
||||
|
||||
async def _verify_stop_resume(
|
||||
*,
|
||||
manifest: Manifest,
|
||||
runtime: str | None,
|
||||
timeout_ms: int | None,
|
||||
workspace_persistence: Literal["tar", "snapshot"],
|
||||
) -> None:
|
||||
client = VercelSandboxClient()
|
||||
options = VercelSandboxClientOptions(
|
||||
runtime=runtime,
|
||||
timeout_ms=timeout_ms,
|
||||
workspace_persistence=workspace_persistence,
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="vercel-snapshot-example-") as snapshot_dir:
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
|
||||
options=options,
|
||||
)
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
await sandbox.write(
|
||||
SNAPSHOT_CHECK_PATH,
|
||||
io.BytesIO(SNAPSHOT_CHECK_CONTENT.encode("utf-8")),
|
||||
)
|
||||
await sandbox.stop()
|
||||
finally:
|
||||
await sandbox.shutdown()
|
||||
|
||||
resumed_sandbox = await client.resume(sandbox.state)
|
||||
try:
|
||||
await resumed_sandbox.start()
|
||||
restored_text = await _read_text(resumed_sandbox, SNAPSHOT_CHECK_PATH)
|
||||
if restored_text != SNAPSHOT_CHECK_CONTENT:
|
||||
raise RuntimeError(
|
||||
f"Snapshot resume verification failed for {workspace_persistence!r}: "
|
||||
f"expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}"
|
||||
)
|
||||
finally:
|
||||
await resumed_sandbox.aclose()
|
||||
|
||||
print(f"snapshot round-trip ok ({workspace_persistence})")
|
||||
|
||||
|
||||
async def _verify_resume_running_sandbox(
|
||||
*,
|
||||
manifest: Manifest,
|
||||
runtime: str | None,
|
||||
timeout_ms: int | None,
|
||||
workspace_persistence: Literal["tar", "snapshot"],
|
||||
) -> None:
|
||||
client = VercelSandboxClient()
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
options=VercelSandboxClientOptions(
|
||||
runtime=runtime,
|
||||
timeout_ms=timeout_ms,
|
||||
workspace_persistence=workspace_persistence,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
await sandbox.write(
|
||||
LIVE_RESUME_CHECK_PATH,
|
||||
io.BytesIO(LIVE_RESUME_CHECK_CONTENT.encode("utf-8")),
|
||||
)
|
||||
serialized = client.serialize_session_state(sandbox.state)
|
||||
resumed_sandbox = await client.resume(client.deserialize_session_state(serialized))
|
||||
try:
|
||||
restored_text = await _read_text(resumed_sandbox, LIVE_RESUME_CHECK_PATH)
|
||||
if restored_text != LIVE_RESUME_CHECK_CONTENT:
|
||||
raise RuntimeError(
|
||||
"Running sandbox resume verification failed: "
|
||||
f"expected {LIVE_RESUME_CHECK_CONTENT!r}, got {restored_text!r}"
|
||||
)
|
||||
finally:
|
||||
await resumed_sandbox.aclose()
|
||||
finally:
|
||||
await sandbox.shutdown()
|
||||
|
||||
print(f"running sandbox resume ok ({workspace_persistence})")
|
||||
|
||||
|
||||
def _fetch_url(url: str) -> str:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
return cast(str, response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _port_check_server_command() -> str:
|
||||
node_path = PORT_CHECK_NODE_SERVER_PATH.as_posix()
|
||||
python_path = PORT_CHECK_PYTHON_SERVER_PATH.as_posix()
|
||||
return (
|
||||
"if command -v node >/dev/null 2>&1; then "
|
||||
f"node {node_path}; "
|
||||
"elif command -v python3 >/dev/null 2>&1; then "
|
||||
f"python3 {python_path}; "
|
||||
"else "
|
||||
"echo 'Neither node nor python3 is available for exposed port verification.' >&2; "
|
||||
"exit 127; "
|
||||
"fi >/tmp/vercel-http.log 2>&1 &"
|
||||
)
|
||||
|
||||
|
||||
async def _verify_exposed_port(
|
||||
*,
|
||||
manifest: Manifest,
|
||||
runtime: str | None,
|
||||
timeout_ms: int | None,
|
||||
workspace_persistence: Literal["tar", "snapshot"],
|
||||
) -> None:
|
||||
client = VercelSandboxClient()
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
options=VercelSandboxClientOptions(
|
||||
runtime=runtime,
|
||||
timeout_ms=timeout_ms,
|
||||
workspace_persistence=workspace_persistence,
|
||||
exposed_ports=(EXPOSED_PORT,),
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
await sandbox.write(
|
||||
PORT_CHECK_NODE_SERVER_PATH,
|
||||
io.BytesIO(PORT_CHECK_NODE_SERVER_CONTENT.encode("utf-8")),
|
||||
)
|
||||
await sandbox.write(
|
||||
PORT_CHECK_PYTHON_SERVER_PATH,
|
||||
io.BytesIO(PORT_CHECK_PYTHON_SERVER_CONTENT.encode("utf-8")),
|
||||
)
|
||||
result = await sandbox.exec(
|
||||
_port_check_server_command(),
|
||||
shell=True,
|
||||
)
|
||||
if not result.ok():
|
||||
raise RuntimeError(
|
||||
f"Failed to start HTTP server for exposed port check: {result.stderr!r}"
|
||||
)
|
||||
|
||||
endpoint = await sandbox.resolve_exposed_port(EXPOSED_PORT)
|
||||
url = f"{'https' if endpoint.tls else 'http'}://{endpoint.host}:{endpoint.port}/"
|
||||
|
||||
last_error: Exception | None = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
body = await asyncio.to_thread(_fetch_url, url)
|
||||
except (TimeoutError, urllib.error.URLError, ValueError) as exc:
|
||||
last_error = exc
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
|
||||
if PORT_CHECK_CONTENT.strip() not in body:
|
||||
raise RuntimeError(f"Exposed port returned unexpected body from {url!r}: {body!r}")
|
||||
print(f"exposed port ok ({workspace_persistence}) -> {url}")
|
||||
return
|
||||
|
||||
raise RuntimeError(f"Exposed port verification failed for {url!r}") from last_error
|
||||
finally:
|
||||
await sandbox.shutdown()
|
||||
|
||||
|
||||
async def main(
|
||||
*,
|
||||
model: str,
|
||||
question: str,
|
||||
runtime: str | None,
|
||||
timeout_ms: int | None,
|
||||
workspace_persistence: Literal["tar", "snapshot"],
|
||||
stream: bool,
|
||||
) -> None:
|
||||
_require_env("OPENAI_API_KEY")
|
||||
_require_vercel_credentials()
|
||||
|
||||
manifest = _build_manifest()
|
||||
|
||||
await _verify_stop_resume(
|
||||
manifest=manifest,
|
||||
runtime=runtime,
|
||||
timeout_ms=timeout_ms,
|
||||
workspace_persistence=workspace_persistence,
|
||||
)
|
||||
await _verify_resume_running_sandbox(
|
||||
manifest=manifest,
|
||||
runtime=runtime,
|
||||
timeout_ms=timeout_ms,
|
||||
workspace_persistence=workspace_persistence,
|
||||
)
|
||||
await _verify_exposed_port(
|
||||
manifest=manifest,
|
||||
runtime=runtime,
|
||||
timeout_ms=timeout_ms,
|
||||
workspace_persistence=workspace_persistence,
|
||||
)
|
||||
|
||||
agent = SandboxAgent(
|
||||
name="Vercel Sandbox Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect the files before answering "
|
||||
"and keep the response concise. "
|
||||
"Do not invent files or statuses that are not present in the workspace. Cite the "
|
||||
"file names you inspected."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
client = VercelSandboxClient()
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
options=VercelSandboxClientOptions(
|
||||
runtime=runtime,
|
||||
timeout_ms=timeout_ms,
|
||||
workspace_persistence=workspace_persistence,
|
||||
),
|
||||
)
|
||||
|
||||
run_config = RunConfig(
|
||||
model_provider=OpenAIProvider(),
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
# Disable tracing because it does not currently work reliably with alternate
|
||||
# upstreams such as AI Gateway, and provider config already comes from env.
|
||||
tracing_disabled=True,
|
||||
workflow_name="Vercel sandbox example",
|
||||
)
|
||||
|
||||
try:
|
||||
async with sandbox:
|
||||
if not stream:
|
||||
result = await Runner.run(agent, question, run_config=run_config)
|
||||
print(result.final_output)
|
||||
return
|
||||
|
||||
stream_result = Runner.run_streamed(agent, question, run_config=run_config)
|
||||
saw_text_delta = False
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(
|
||||
event.data, ResponseTextDeltaEvent
|
||||
):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
parser.add_argument(
|
||||
"--runtime",
|
||||
default=None,
|
||||
help="Optional Vercel runtime, for example `node22` or `python3.14`.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout-ms",
|
||||
type=int,
|
||||
default=120_000,
|
||||
help="Optional Vercel sandbox timeout in milliseconds.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workspace-persistence",
|
||||
choices=("tar", "snapshot"),
|
||||
default="tar",
|
||||
help="Workspace persistence mode to verify before the agent run.",
|
||||
)
|
||||
parser.add_argument("--stream", action="store_true", default=False, help="Stream the response.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
model=args.model,
|
||||
question=args.question,
|
||||
runtime=args.runtime,
|
||||
timeout_ms=args.timeout_ms,
|
||||
workspace_persistence=cast(Literal["tar", "snapshot"], args.workspace_persistence),
|
||||
stream=args.stream,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Show how a non-sandbox agent can hand work to a sandbox agent.
|
||||
|
||||
The intake agent never sees a workspace directly. It hands document-heavy work
|
||||
to a sandbox reviewer, and that reviewer then hands the synthesized result to a
|
||||
plain account-facing writer.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from agents import Agent, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
DEFAULT_QUESTION = (
|
||||
"Review the attached onboarding packet and draft a short internal note for the account "
|
||||
"executive about what to confirm before kickoff."
|
||||
)
|
||||
|
||||
|
||||
async def main(model: str, question: str) -> None:
|
||||
# The manifest becomes the workspace that only the sandbox reviewer can inspect.
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"customer_background.md": (
|
||||
"# Customer background\n\n"
|
||||
"- Customer: Bluebird Logistics.\n"
|
||||
"- Region: North America.\n"
|
||||
"- New purchase: analytics workspace plus SSO.\n"
|
||||
),
|
||||
"kickoff_checklist.md": (
|
||||
"# Kickoff checklist\n\n"
|
||||
"- Security questionnaire is still in review.\n"
|
||||
"- Two customer admins still need to complete access training.\n"
|
||||
"- Target kickoff date is next Tuesday.\n"
|
||||
),
|
||||
"implementation_scope.md": (
|
||||
"# Implementation scope\n\n"
|
||||
"- The customer wants historical data migration for 5 years of records.\n"
|
||||
"- Data engineering support is available only starting next month.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# This final agent does not inspect files. It only rewrites reviewed facts into a note.
|
||||
account_manager = Agent(
|
||||
name="Account Executive Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You write concise internal updates for account teams. Convert the sandbox review "
|
||||
"into a short note with a headline, the top risks, and a recommended next step."
|
||||
),
|
||||
)
|
||||
|
||||
# This sandbox agent can inspect the workspace, then hand its findings to the writer above.
|
||||
sandbox_reviewer = SandboxAgent(
|
||||
name="Onboarding Packet Reviewer",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You inspect onboarding documents in the sandbox, verify the facts, then hand off "
|
||||
"to the account executive assistant to draft the final note. Do not answer the user "
|
||||
"directly after reviewing the packet."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
handoffs=[account_manager],
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
)
|
||||
|
||||
# The starting agent is a normal agent. It only decides when to hand off into the sandbox.
|
||||
intake_agent = Agent(
|
||||
name="Deal Desk Intake",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You triage internal requests. If a request depends on attached documents, hand off "
|
||||
"to the onboarding packet reviewer immediately."
|
||||
),
|
||||
handoffs=[sandbox_reviewer],
|
||||
)
|
||||
|
||||
result = await Runner.run(
|
||||
intake_agent,
|
||||
question,
|
||||
run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())),
|
||||
)
|
||||
print(result.final_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.4", help="Model name to use.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(main(args.model, args.question))
|
||||
@@ -0,0 +1,86 @@
|
||||
# Healthcare support
|
||||
|
||||
This example shows how to build a healthcare support workflow with Agents SDK using both
|
||||
standard agents and a sandbox agent. The scenario is intentionally synthetic and generic: a patient
|
||||
asks a billing or coverage question, the workflow checks local records, inspects policy documents in
|
||||
an isolated sandbox workspace, writes support artifacts, and optionally routes one ambiguous case to
|
||||
a human reviewer.
|
||||
|
||||
## What this example demonstrates
|
||||
|
||||
- **Standard agent orchestration** with a top-level support orchestrator and a benefits subagent.
|
||||
- **Sandbox agents** with a mounted workspace, shell commands, a generated output folder, and
|
||||
runtime-selected sandbox config.
|
||||
- **Sandbox capabilities** including `Shell`, `Filesystem`, and lazy-loaded `Skills`.
|
||||
- **Human-in-the-loop approvals** using an approval-gated queue-routing tool.
|
||||
- **Persistent memory** with `SQLiteSession`, shared across scenario runs.
|
||||
- **Structured outputs** for each specialist agent and the final case resolution.
|
||||
- **Tracing** so you can inspect every model call and tool call in the OpenAI trace viewer.
|
||||
- **CLI-first workflow** that can be run scenario by scenario from the repository checkout.
|
||||
|
||||
## Architecture
|
||||
|
||||
The workflow has two execution modes working together:
|
||||
|
||||
1. A **standard orchestrator agent** runs in the normal Agents SDK loop, calls the benefits
|
||||
subagent first, then calls a sandbox agent tool, and decides whether to request a human handoff.
|
||||
2. A **sandbox policy agent** runs behind `agents.sandbox`, reads the mounted case files and policy
|
||||
documents, uses shell commands plus a lazily loaded skill, writes markdown artifacts into
|
||||
`output/`, and returns a structured policy summary.
|
||||
|
||||
The local fixture data lives in `data/scenarios/*.json` and `data/fixtures/*.json`. The sandbox
|
||||
policy library lives in `policies/*.md`. Generated artifacts are copied to
|
||||
`.cache/healthcare_support/output/<scenario_id>/`.
|
||||
|
||||
## Scenarios
|
||||
|
||||
The built-in scenarios increase in complexity:
|
||||
|
||||
- `eligibility_verification_basic` checks a straightforward benefits question.
|
||||
- `referral_status_check` adds a referral lookup.
|
||||
- `blue_cross_pt_benefits` shows a follow-up turn that benefits from the shared SQLite memory.
|
||||
- `prior_auth_confusion_ct` focuses on prior-authorization and intake-routing confusion.
|
||||
- `billing_coverage_clarification` combines benefits lookup with sandbox policy search and document
|
||||
generation.
|
||||
- `messy_ambiguous_knee_case` triggers the human approval flow before queueing a handoff.
|
||||
|
||||
## Run the CLI demo
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/healthcare_support/main.py
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/healthcare_support/main.py --list-scenarios
|
||||
uv run python examples/sandbox/healthcare_support/main.py --scenario blue_cross_pt_benefits
|
||||
uv run python examples/sandbox/healthcare_support/main.py --scenario messy_ambiguous_knee_case
|
||||
uv run python examples/sandbox/healthcare_support/main.py --reset-memory
|
||||
```
|
||||
|
||||
For unattended runs, set `EXAMPLES_INTERACTIVE_MODE=auto` to auto-answer prompts:
|
||||
|
||||
```bash
|
||||
EXAMPLES_INTERACTIVE_MODE=auto uv run python examples/sandbox/healthcare_support/main.py --scenario messy_ambiguous_knee_case
|
||||
```
|
||||
|
||||
## Files to read first
|
||||
|
||||
- [`main.py`](./main.py) runs the standalone CLI demo.
|
||||
- [`workflow.py`](./workflow.py) contains the shared workflow execution logic, sandbox setup,
|
||||
artifact copying, tracing, and approval resume loop.
|
||||
- [`support_agents.py`](./support_agents.py) defines the orchestrator, benefits subagent, sandbox
|
||||
policy agent, and memory recap agent.
|
||||
- [`tools.py`](./tools.py) defines the local lookup tools and the approval-gated human handoff tool.
|
||||
- [`skills/prior-auth-packet-builder/SKILL.md`](./skills/prior-auth-packet-builder/SKILL.md) is the
|
||||
sandbox skill loaded at runtime.
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a demo workflow, not a production healthcare system.
|
||||
- All patient, payer, and policy data in this example is synthetic.
|
||||
- The example loads environment defaults from the repository-root `.env` file and from this demo's
|
||||
optional local `.env` file.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user