Files
openai--openai-agents-python/examples/sandbox/extensions/temporal/temporal_session_manager.py
T
Steve Coffey 2d665c9a67 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>
2026-04-15 10:00:40 -07:00

407 lines
15 KiB
Python

# 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