chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:17 +08:00
commit 4ed4e9ff99
1368 changed files with 334957 additions and 0 deletions
@@ -0,0 +1,86 @@
# 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.
## Local smoke test
If you only want to confirm that Temporal workflows run locally, use the minimal
example first:
```
export OPENAI_API_KEY="sk-..."
# Optional: export EXAMPLES_TEMPORAL_MODEL="gpt-5.4-mini"
# Optional: export EXAMPLES_TEMPORAL_TRACE="openai"
uv run --extra temporal python -m examples.sandbox.extensions.temporal.local_hello_workflow
```
This starts the Temporal Python SDK test server, runs one workflow and one model activity, connects the workflow to a local Unix sandbox, and then shuts down. It does not require the Temporal CLI, an already running Temporal dev server, or sandbox backend credentials.
The local smoke test enables OpenAI Agents tracing by default. Set `EXAMPLES_TEMPORAL_TRACE=none` to disable tracing, or `EXAMPLES_TEMPORAL_TRACE=openai_with_temporal_spans` to also ask the Temporal plugin to add Temporal spans. The Temporal span mode depends on Temporal plugin behavior and may omit regular Agents spans with some plugin versions; use the default `openai` mode when you want standard OpenAI trace spans.
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 before starting.
## 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,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,21 @@
# Temporal Sandbox Agent
set dotenv-load
set dotenv-path := ".env"
# 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
uv run --extra temporal --extra daytona --extra e2b --extra docker python temporal_sandbox_agent.py worker
# Start the TUI client
tui: sync
uv run --extra temporal --extra daytona --extra e2b --extra docker python temporal_sandbox_agent.py run
@@ -0,0 +1,150 @@
"""Minimal local Temporal SandboxAgent workflow example.
This example is intentionally smaller than ``temporal_sandbox_agent.py``. It starts a local
Temporal test server through the Temporal Python SDK, runs a ``SandboxAgent`` workflow against
the local Unix sandbox backend, and then shuts everything down.
It does not require the Temporal CLI, a long-running Temporal server, or cloud sandbox backend
credentials. It does require ``OPENAI_API_KEY`` because the model call runs through the Temporal
OpenAI Agents plugin as an activity.
Usage:
uv run --extra temporal python -m examples.sandbox.extensions.temporal.local_hello_workflow
"""
from __future__ import annotations
import asyncio
import os
from datetime import timedelta
from temporalio import workflow
from temporalio.client import Client
from temporalio.contrib.openai_agents import (
ModelActivityParameters,
OpenAIAgentsPlugin,
SandboxClientProvider,
)
from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.capabilities import Shell
from agents.sandbox.entries import File
from agents.sandbox.sandboxes import UnixLocalSandboxClient, UnixLocalSandboxClientOptions
TASK_QUEUE = "local-temporal-sandbox-agent"
WORKFLOW_ID = "local-temporal-sandbox-agent-workflow"
DEFAULT_MODEL = "gpt-5.4-mini"
EXPECTED_GREETING = "Temporal sandbox says hello from a local file"
TRACE_MODE_NONE = "none"
TRACE_MODE_OPENAI = "openai"
TRACE_MODE_OPENAI_WITH_TEMPORAL_SPANS = "openai_with_temporal_spans"
TRACE_MODES = {
TRACE_MODE_NONE,
TRACE_MODE_OPENAI,
TRACE_MODE_OPENAI_WITH_TEMPORAL_SPANS,
}
@workflow.defn
class LocalSandboxAgentWorkflow:
@workflow.run
async def run(self, model: str, trace_mode: str) -> str:
agent = SandboxAgent(
name="Local Temporal Sandbox Agent",
model=model,
instructions=(
"Inspect the sandbox workspace with the shell tool before answering. "
"Report the greeting from README.md exactly."
),
default_manifest=Manifest(
entries={
"README.md": File(content=b"Temporal sandbox says hello from a local file.\n"),
}
),
capabilities=[Shell()],
model_settings=ModelSettings(tool_choice="required"),
)
result = await Runner.run(
agent,
"Read README.md and report its greeting.",
run_config=RunConfig(
sandbox=SandboxRunConfig(
client=temporal_sandbox_client("local"),
options=UnixLocalSandboxClientOptions(),
),
workflow_name="Local Temporal SandboxAgent workflow",
tracing_disabled=trace_mode == TRACE_MODE_NONE,
),
)
return str(result.final_output)
def _client_with_plugin(client: Client, trace_mode: str) -> Client:
plugin = OpenAIAgentsPlugin(
model_params=ModelActivityParameters(start_to_close_timeout=timedelta(seconds=120)),
sandbox_clients=[SandboxClientProvider("local", UnixLocalSandboxClient())],
add_temporal_spans=trace_mode == TRACE_MODE_OPENAI_WITH_TEMPORAL_SPANS,
)
config = client.config()
config["plugins"] = [*config.get("plugins", []), plugin]
return Client(**config)
def _require_env(name: str) -> None:
if not os.environ.get(name):
raise SystemExit(f"{name} must be set before running this example.")
def _trace_mode_from_env() -> str:
trace_mode = os.getenv("EXAMPLES_TEMPORAL_TRACE", TRACE_MODE_OPENAI).strip().lower()
if trace_mode not in TRACE_MODES:
supported = ", ".join(sorted(TRACE_MODES))
raise SystemExit(
f"EXAMPLES_TEMPORAL_TRACE must be one of: {supported}. Got {trace_mode!r}."
)
return trace_mode
async def main() -> None:
_require_env("OPENAI_API_KEY")
model = os.getenv("EXAMPLES_TEMPORAL_MODEL", DEFAULT_MODEL)
trace_mode = _trace_mode_from_env()
print(f"Using model: {model}")
print(f"Using trace mode: {trace_mode}")
print("Starting local Temporal test server...")
async with await WorkflowEnvironment.start_time_skipping() as env:
client = _client_with_plugin(env.client, trace_mode)
print("Starting local Temporal worker...")
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[LocalSandboxAgentWorkflow],
workflow_runner=SandboxedWorkflowRunner(
restrictions=SandboxRestrictions.default.with_passthrough_modules(
"annotated_types",
"pydantic_core",
),
),
):
result = await client.execute_workflow(
LocalSandboxAgentWorkflow.run,
args=[model, trace_mode],
id=WORKFLOW_ID,
task_queue=TASK_QUEUE,
)
print(f"Workflow result: {result}")
if EXPECTED_GREETING not in result:
raise RuntimeError(f"Expected workflow result to contain {EXPECTED_GREETING!r}.")
print("Local Temporal SandboxAgent workflow completed successfully.")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,722 @@
"""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 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.6-sol") -> 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 (
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(
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