chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# 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.6-sol", 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.6-sol", 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.6-sol"),
|
||||
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,260 @@
|
||||
"""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.6-sol"
|
||||
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(
|
||||
# This is a host path read by the SDK process.
|
||||
# Requested skills are copied into `skills_path` in the sandbox.
|
||||
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,5 @@
|
||||
# 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,14 @@
|
||||
# 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,14 @@
|
||||
---
|
||||
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,353 @@
|
||||
# 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.6-sol"
|
||||
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.6-sol"
|
||||
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.6-sol", 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,79 @@
|
||||
# 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,540 @@
|
||||
"""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.auto_mode import input_with_fallback, is_auto_mode
|
||||
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"
|
||||
DEFAULT_AUTO_QUESTION = "What are NASA's top 5 contractors by total obligations?"
|
||||
|
||||
WORKSPACE_ROOT = DEFAULT_DAYTONA_WORKSPACE_ROOT
|
||||
|
||||
|
||||
def build_agent() -> SandboxAgent:
|
||||
"""Build the agent blueprint."""
|
||||
generate_memory = not is_auto_mode()
|
||||
manifest = Manifest(
|
||||
root=WORKSPACE_ROOT,
|
||||
entries={
|
||||
"setup_db.py": LocalFile(src=SETUP_DB_PATH),
|
||||
"schema": LocalDir(src=SCHEMA_DIR),
|
||||
"data": Dir(ephemeral=True),
|
||||
"memories/MEMORY.md": File(content=b""),
|
||||
"memories/memory_summary.md": File(content=b""),
|
||||
"memories/phase_two_selection.json": File(content=b""),
|
||||
},
|
||||
)
|
||||
|
||||
return SandboxAgent(
|
||||
name="NASA Spending Q&A",
|
||||
default_manifest=manifest,
|
||||
model="gpt-5.6-sol",
|
||||
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)."
|
||||
),
|
||||
)
|
||||
if generate_memory
|
||||
else None
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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))
|
||||
|
||||
|
||||
def _require_env(name: str) -> None:
|
||||
"""Exit early with a clear message when a required environment variable is missing."""
|
||||
if os.environ.get(name):
|
||||
return
|
||||
raise SystemExit(f"{name} must be set before running this example.")
|
||||
|
||||
|
||||
def _status(message: str) -> None:
|
||||
"""Print progress immediately so automation logs show where startup is blocked."""
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
_status("Starting Daytona NASA spending text-to-SQL example...")
|
||||
_require_env("OPENAI_API_KEY")
|
||||
_require_env("DAYTONA_API_KEY")
|
||||
|
||||
agent = build_agent()
|
||||
|
||||
instrumentation = Instrumentation(
|
||||
sinks=[JsonlOutboxSink(AUDIT_LOG_PATH)],
|
||||
payload_policy=EventPayloadPolicy(include_exec_output=True),
|
||||
)
|
||||
RESULTS_PORT = 8080
|
||||
|
||||
_status("Creating Daytona sandbox client...")
|
||||
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:
|
||||
_status(f"Resuming Daytona sandbox {old_sandbox_id}...")
|
||||
sandbox = await client.resume(saved_state)
|
||||
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
|
||||
if sandbox.state.sandbox_id == old_sandbox_id:
|
||||
_status("Reconnected to existing sandbox.")
|
||||
else:
|
||||
_status("Previous sandbox no longer exists. Created a new one.")
|
||||
except Exception as e:
|
||||
_status(f"Could not resume previous sandbox: {e}")
|
||||
saved_state = None
|
||||
sandbox = None
|
||||
|
||||
if sandbox is None:
|
||||
_status("Creating Daytona sandbox...")
|
||||
sandbox = await client.create(manifest=agent.default_manifest, options=client_options)
|
||||
|
||||
_status("Starting Daytona sandbox...")
|
||||
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).
|
||||
_status("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.
|
||||
_status("Starting results file server...")
|
||||
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] = []
|
||||
auto_mode = is_auto_mode()
|
||||
|
||||
while True:
|
||||
try:
|
||||
if auto_mode:
|
||||
question = input_with_fallback("> ", DEFAULT_AUTO_QUESTION)
|
||||
else:
|
||||
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 auto_mode:
|
||||
break
|
||||
|
||||
if destroy:
|
||||
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
|
||||
sandbox.state.pause_on_exit = False
|
||||
SESSION_STATE_PATH.unlink(missing_ok=True)
|
||||
_status("Deleting sandbox...")
|
||||
else:
|
||||
assert isinstance(sandbox.state, DaytonaSandboxSessionState)
|
||||
_save_session_state(sandbox.state)
|
||||
_status("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,718 @@
|
||||
#!/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 functools
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ARTIFACT_ROOT = Path(os.environ.get("EXAMPLES_ARTIFACTS_DIR", "."))
|
||||
DB_DIR = ARTIFACT_ROOT / "data"
|
||||
DB_PATH = DB_DIR / "usaspending.db"
|
||||
GLOSSARY_PATH = ARTIFACT_ROOT / "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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _urlopen_ssl_context() -> ssl.SSLContext | None:
|
||||
"""Use certifi's CA bundle when available, otherwise keep stdlib defaults."""
|
||||
try:
|
||||
import certifi
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
return ssl.create_default_context(cafile=certifi.where())
|
||||
|
||||
|
||||
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
|
||||
ssl_context = _urlopen_ssl_context()
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ssl_context) 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 = DB_DIR / "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.6-sol", 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.6-sol", 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.6-sol"
|
||||
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.6-sol", 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,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
|
||||
@@ -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.6-sol", 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.6-sol", 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,71 @@
|
||||
# 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.
|
||||
@@ -0,0 +1 @@
|
||||
"""Synthetic healthcare support sandbox example."""
|
||||
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from examples.sandbox.healthcare_support.models import KnowledgeSnippet, ScenarioCase
|
||||
|
||||
EXAMPLE_ROOT = Path(__file__).resolve().parent
|
||||
SCENARIOS_DIR = EXAMPLE_ROOT / "data" / "scenarios"
|
||||
FIXTURES_DIR = EXAMPLE_ROOT / "data" / "fixtures"
|
||||
POLICIES_DIR = EXAMPLE_ROOT / "policies"
|
||||
ROOT_ENV_PATH = EXAMPLE_ROOT.parents[2] / ".env"
|
||||
DEMO_ENV_PATH = EXAMPLE_ROOT / ".env"
|
||||
|
||||
|
||||
def load_root_env() -> None:
|
||||
"""Load environment defaults from the repository root and this demo folder."""
|
||||
for env_path in (ROOT_ENV_PATH, DEMO_ENV_PATH):
|
||||
if not env_path.exists():
|
||||
continue
|
||||
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
return " ".join(re.findall(r"[a-z0-9]+", value.lower()))
|
||||
|
||||
|
||||
def tokenize(value: str) -> set[str]:
|
||||
return set(re.findall(r"[a-z0-9]+", value.lower()))
|
||||
|
||||
|
||||
def normalize_date(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%Y/%m/%d", "%m-%d-%Y"):
|
||||
try:
|
||||
return datetime.strptime(value, fmt).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
return "".join(re.findall(r"\d+", value))
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyDocument:
|
||||
document_id: str
|
||||
title: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthcareSupportDataStore:
|
||||
scenarios: dict[str, ScenarioCase]
|
||||
patient_records: list[dict[str, Any]]
|
||||
eligibility_records: list[dict[str, Any]]
|
||||
referral_records: list[dict[str, Any]]
|
||||
policy_documents: list[PolicyDocument]
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> HealthcareSupportDataStore:
|
||||
scenarios = {
|
||||
path.stem: ScenarioCase.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
||||
for path in sorted(SCENARIOS_DIR.glob("*.json"))
|
||||
}
|
||||
patient_records = json.loads(
|
||||
(FIXTURES_DIR / "patient_profiles.json").read_text(encoding="utf-8")
|
||||
)["records"]
|
||||
eligibility_records = json.loads(
|
||||
(FIXTURES_DIR / "insurance_eligibility.json").read_text(encoding="utf-8")
|
||||
)["records"]
|
||||
referral_records = json.loads(
|
||||
(FIXTURES_DIR / "referral_status.json").read_text(encoding="utf-8")
|
||||
)["records"]
|
||||
policy_documents = [
|
||||
PolicyDocument(
|
||||
document_id=path.stem,
|
||||
title=path.stem.replace("_", " ").title(),
|
||||
text=path.read_text(encoding="utf-8"),
|
||||
)
|
||||
for path in sorted(POLICIES_DIR.glob("*.md"))
|
||||
]
|
||||
return cls(
|
||||
scenarios=scenarios,
|
||||
patient_records=patient_records,
|
||||
eligibility_records=eligibility_records,
|
||||
referral_records=referral_records,
|
||||
policy_documents=policy_documents,
|
||||
)
|
||||
|
||||
def list_scenario_ids(self) -> list[str]:
|
||||
return sorted(self.scenarios)
|
||||
|
||||
def get_scenario(self, scenario_id: str) -> ScenarioCase:
|
||||
try:
|
||||
return self.scenarios[scenario_id]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"Unknown scenario_id: {scenario_id}") from exc
|
||||
|
||||
def search_policies(self, query: str, top_k: int = 4) -> list[KnowledgeSnippet]:
|
||||
query_terms = tokenize(query)
|
||||
if not query_terms:
|
||||
return []
|
||||
|
||||
scored: list[KnowledgeSnippet] = []
|
||||
for document in self.policy_documents:
|
||||
matched_terms = sorted(query_terms & tokenize(document.text))
|
||||
if not matched_terms:
|
||||
continue
|
||||
score = round(len(matched_terms) / max(len(query_terms), 1), 4)
|
||||
snippet = " ".join(document.text.split())[:320]
|
||||
scored.append(
|
||||
KnowledgeSnippet(
|
||||
document_id=document.document_id,
|
||||
title=document.title,
|
||||
chunk_id=f"{document.document_id}:0",
|
||||
score=score,
|
||||
snippet=snippet,
|
||||
matched_terms=matched_terms,
|
||||
)
|
||||
)
|
||||
|
||||
scored.sort(key=lambda item: item.score, reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
def lookup_patient(
|
||||
self,
|
||||
*,
|
||||
patient_id: str | None = None,
|
||||
phone: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
for record in self.patient_records:
|
||||
if patient_id and record.get("patient_id") == patient_id:
|
||||
return {"lookup_status": "matched", "record": record}
|
||||
if phone and record.get("phone") == phone:
|
||||
return {"lookup_status": "matched", "record": record}
|
||||
if name and normalize_text(record.get("name", "")) == normalize_text(name):
|
||||
return {"lookup_status": "matched", "record": record}
|
||||
return {"lookup_status": "not_found", "record": None}
|
||||
|
||||
def lookup_eligibility(
|
||||
self,
|
||||
*,
|
||||
payer: str | None = None,
|
||||
member_id: str | None = None,
|
||||
dob: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payer_norm = normalize_text(payer or "")
|
||||
dob_norm = normalize_date(dob)
|
||||
fallback_match: dict[str, Any] | None = None
|
||||
|
||||
for record in self.eligibility_records:
|
||||
if member_id and record.get("member_id") != member_id:
|
||||
continue
|
||||
if dob_norm and normalize_date(record.get("dob")) != dob_norm:
|
||||
continue
|
||||
if payer_norm:
|
||||
if normalize_text(record.get("payer", "")) == payer_norm:
|
||||
return {"lookup_status": "matched", **record}
|
||||
continue
|
||||
if fallback_match is None:
|
||||
fallback_match = {"lookup_status": "matched", **record}
|
||||
|
||||
if fallback_match is not None:
|
||||
return fallback_match
|
||||
|
||||
return {
|
||||
"lookup_status": "not_found",
|
||||
"eligibility_status": "unknown",
|
||||
"notes": "No eligibility match. Ask for payer, member ID, and date of birth.",
|
||||
}
|
||||
|
||||
def lookup_referral(
|
||||
self,
|
||||
*,
|
||||
referral_id: str | None = None,
|
||||
patient_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
for record in self.referral_records:
|
||||
if referral_id and record.get("referral_id") == referral_id:
|
||||
return {"lookup_status": "matched", **record}
|
||||
if patient_id and record.get("patient_id") == patient_id:
|
||||
return {"lookup_status": "matched", **record}
|
||||
return {"lookup_status": "not_found", "status": "unknown"}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-4439201",
|
||||
"dob": "1985-02-14",
|
||||
"plan_name": "Blue Cross PPO Silver 4500",
|
||||
"eligibility_status": "active",
|
||||
"copay_primary_care": "$35",
|
||||
"copay_specialist": "$60",
|
||||
"deductible_remaining": "$1,200",
|
||||
"prior_auth_required_services": [
|
||||
"mri",
|
||||
"ct angiogram",
|
||||
"elective surgery"
|
||||
],
|
||||
"notes": "Coverage active. MRI requires prior authorization except emergency use."
|
||||
},
|
||||
{
|
||||
"payer": "UnitedHealthcare",
|
||||
"member_id": "UHC-771032",
|
||||
"dob": "1990-09-03",
|
||||
"plan_name": "UHC Choice Plus Bronze",
|
||||
"eligibility_status": "active",
|
||||
"copay_primary_care": "$30",
|
||||
"copay_specialist": "$75",
|
||||
"deductible_remaining": "$2,050",
|
||||
"prior_auth_required_services": [
|
||||
"ct angiogram",
|
||||
"inpatient admission",
|
||||
"outpatient surgery"
|
||||
],
|
||||
"notes": "Prior auth required for CT angiogram unless ordered in emergency setting."
|
||||
},
|
||||
{
|
||||
"payer": "Aetna",
|
||||
"member_id": "AET-562100",
|
||||
"dob": "1978-11-20",
|
||||
"plan_name": "Aetna Open Access Basic",
|
||||
"eligibility_status": "active",
|
||||
"copay_primary_care": "$25",
|
||||
"copay_specialist": "$50",
|
||||
"deductible_remaining": "$850",
|
||||
"prior_auth_required_services": [
|
||||
"specialist consult"
|
||||
],
|
||||
"notes": "Referral on file for specialist consult."
|
||||
},
|
||||
{
|
||||
"payer": "Cigna",
|
||||
"member_id": "CG-291001",
|
||||
"dob": "1982-06-30",
|
||||
"plan_name": "Cigna Connect Gold",
|
||||
"eligibility_status": "active",
|
||||
"copay_primary_care": "$20",
|
||||
"copay_specialist": "$45",
|
||||
"deductible_remaining": "$300",
|
||||
"prior_auth_required_services": [
|
||||
"advanced imaging",
|
||||
"elective procedures"
|
||||
],
|
||||
"notes": "Claims for advanced imaging can deny if authorization is missing."
|
||||
},
|
||||
{
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-8822009",
|
||||
"dob": "1974-05-12",
|
||||
"plan_name": "Blue Cross PPO Platinum",
|
||||
"eligibility_status": "active",
|
||||
"copay_primary_care": "$20",
|
||||
"copay_specialist": "$40",
|
||||
"deductible_remaining": "$0",
|
||||
"prior_auth_required_services": [
|
||||
"physical therapy after 12 visits"
|
||||
],
|
||||
"notes": "Physical therapy benefit allows 12 visits without prior authorization per calendar year."
|
||||
},
|
||||
{
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-9017710",
|
||||
"dob": "1992-04-17",
|
||||
"plan_name": "Blue Cross PPO Silver 3000",
|
||||
"eligibility_status": "active",
|
||||
"copay_primary_care": "$30",
|
||||
"copay_specialist": "$55",
|
||||
"deductible_remaining": "$1,600",
|
||||
"prior_auth_required_services": [
|
||||
"mri",
|
||||
"knee surgery consult",
|
||||
"outpatient surgery"
|
||||
],
|
||||
"notes": "Prior auth normally required for knee surgery consult and advanced imaging."
|
||||
}
|
||||
],
|
||||
"default_response": {
|
||||
"eligibility_status": "unknown",
|
||||
"notes": "No eligibility match. Confirm payer, member ID, and DOB."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"patient_id": "PAT-1001",
|
||||
"name": "Maya Thompson",
|
||||
"dob": "1985-02-14",
|
||||
"phone": "555-0111",
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-4439201",
|
||||
"referral_id": "REF-44120"
|
||||
},
|
||||
{
|
||||
"patient_id": "PAT-1002",
|
||||
"name": "Victor Chen",
|
||||
"dob": "1990-09-03",
|
||||
"phone": "555-0122",
|
||||
"payer": "UnitedHealthcare",
|
||||
"member_id": "UHC-771032",
|
||||
"referral_id": "REF-77100"
|
||||
},
|
||||
{
|
||||
"patient_id": "PAT-1003",
|
||||
"name": "Nora Patel",
|
||||
"dob": "1978-11-20",
|
||||
"phone": "555-0133",
|
||||
"payer": "Aetna",
|
||||
"member_id": "AET-562100",
|
||||
"referral_id": "REF-88421"
|
||||
},
|
||||
{
|
||||
"patient_id": "PAT-1004",
|
||||
"name": "Luis Romero",
|
||||
"dob": "1982-06-30",
|
||||
"phone": "555-0144",
|
||||
"payer": "Cigna",
|
||||
"member_id": "CG-291001",
|
||||
"referral_id": "REF-12880"
|
||||
},
|
||||
{
|
||||
"patient_id": "PAT-1005",
|
||||
"name": "Ella Brooks",
|
||||
"dob": "1974-05-12",
|
||||
"phone": "555-0155",
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-8822009",
|
||||
"referral_id": "REF-33002"
|
||||
},
|
||||
{
|
||||
"patient_id": "PAT-1006",
|
||||
"name": "Jordan Lee",
|
||||
"dob": "1992-04-17",
|
||||
"phone": "555-0134",
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-9017710",
|
||||
"referral_id": "REF-90171"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"referral_id": "REF-88421",
|
||||
"patient_id": "PAT-1003",
|
||||
"status": "approved",
|
||||
"specialty": "Cardiology",
|
||||
"requested_provider": "Dr. Ramos",
|
||||
"authorized_visits": 6,
|
||||
"remaining_visits": 4,
|
||||
"notes": "Authorization valid through 2026-07-31."
|
||||
},
|
||||
{
|
||||
"referral_id": "REF-77100",
|
||||
"patient_id": "PAT-1002",
|
||||
"status": "pending_clinical_review",
|
||||
"specialty": "Radiology",
|
||||
"requested_provider": "Riverfront Imaging",
|
||||
"authorized_visits": 1,
|
||||
"remaining_visits": 0,
|
||||
"notes": "Pending prior authorization packet completion."
|
||||
},
|
||||
{
|
||||
"referral_id": "REF-90171",
|
||||
"patient_id": "PAT-1006",
|
||||
"status": "pending",
|
||||
"specialty": "Orthopedics",
|
||||
"requested_provider": "Summit Ortho Group",
|
||||
"authorized_visits": 8,
|
||||
"remaining_visits": 8,
|
||||
"notes": "Awaiting payer determination."
|
||||
}
|
||||
]
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"scenario_id": "billing_coverage_clarification",
|
||||
"description": "Patient received an unexpected imaging bill and wants coverage clarification.",
|
||||
"transcript": "Hey, this is Luis Romero. I got a bill after an ultrasound on 2026-02-08 and I thought it was covered.\nMy insurance is Cigna and my member ID is CG-291001.\nCan someone explain what happened and what I should do now?",
|
||||
"patient_metadata": {
|
||||
"patient_id": "PAT-1004"
|
||||
},
|
||||
"followup_qa": {
|
||||
"date of service": "2026-02-08",
|
||||
"payer": "Cigna"
|
||||
},
|
||||
"expected": {
|
||||
"intent": "billing_coverage_clarification",
|
||||
"required_entities": {
|
||||
"payer": "Cigna",
|
||||
"member_id": "CG-291001"
|
||||
},
|
||||
"required_tool_calls": [
|
||||
"insurance_eligibility_lookup"
|
||||
],
|
||||
"required_resolution_elements": [
|
||||
"billing coverage review",
|
||||
"recommended next step"
|
||||
],
|
||||
"expected_payer": "Cigna"
|
||||
},
|
||||
"gold": {
|
||||
"expected_next_step": "Route to billing review with EOB and service date context."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"scenario_id": "blue_cross_pt_benefits",
|
||||
"description": "Blue Cross member asks about remaining physical therapy benefit and coverage path.",
|
||||
"transcript": "This is Ella Brooks. I am a Blue Cross member and my ID is BCX-8822009.\nI am trying to continue physical therapy and need to know if I still have covered visits left.\nI do not have my date of birth in front of me if you need it.",
|
||||
"patient_metadata": {
|
||||
"patient_id": "PAT-1005"
|
||||
},
|
||||
"followup_qa": {
|
||||
"date of birth": "05/12/1974",
|
||||
"physical therapy": "physical therapy"
|
||||
},
|
||||
"expected": {
|
||||
"intent": "eligibility_verification",
|
||||
"required_entities": {
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-8822009"
|
||||
},
|
||||
"required_tool_calls": [
|
||||
"insurance_eligibility_lookup"
|
||||
],
|
||||
"required_resolution_elements": [
|
||||
"eligibility verified",
|
||||
"recommended next step"
|
||||
],
|
||||
"expected_payer": "Blue Cross"
|
||||
},
|
||||
"gold": {
|
||||
"expected_next_step": "Confirm PT visit limits and advise on when additional review is needed."
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"scenario_id": "eligibility_verification_basic",
|
||||
"description": "Basic eligibility verification call with clear Blue Cross identifiers.",
|
||||
"transcript": "Hi, this is Maya Thompson. I have an MRI next week and I want to confirm if it is covered.\nI have Blue Cross and my member ID is BCX-4439201. My date of birth is 02/14/1985.\nCan you tell me what my benefits look like and what I should do next?",
|
||||
"patient_metadata": {
|
||||
"patient_id": "PAT-1001"
|
||||
},
|
||||
"followup_qa": {
|
||||
"member ID": "BCX-4439201",
|
||||
"date of birth": "02/14/1985"
|
||||
},
|
||||
"expected": {
|
||||
"intent": "eligibility_verification",
|
||||
"required_entities": {
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-4439201"
|
||||
},
|
||||
"required_tool_calls": [
|
||||
"insurance_eligibility_lookup"
|
||||
],
|
||||
"required_resolution_elements": [
|
||||
"eligibility verified",
|
||||
"recommended next step"
|
||||
],
|
||||
"expected_payer": "Blue Cross"
|
||||
},
|
||||
"gold": {
|
||||
"expected_next_step": "Confirm prior auth requirement for MRI and proceed with scheduling."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"scenario_id": "messy_ambiguous_knee_case",
|
||||
"description": "Messy real-world call with ambiguous details requiring follow-up, retrieval, and multiple tool invocations.",
|
||||
"transcript": "Hi, this is Jordan Lee. I had a knee surgery consult and maybe some imaging planned, then I got mixed messages about auth.\nI also saw a bill and I am not sure if this is Blue something PPO or what.\nMy phone is 555-0134 and I think the referral might be REF-90171.\nCan you figure out what I need to do next?",
|
||||
"patient_metadata": {
|
||||
"patient_id": "PAT-1006"
|
||||
},
|
||||
"followup_qa": {
|
||||
"insurance payer": "Blue Cross",
|
||||
"member ID": "BCX-9017710",
|
||||
"date of birth": "04/17/1992",
|
||||
"procedure or visit type": "knee surgery consult",
|
||||
"referral ID": "REF-90171"
|
||||
},
|
||||
"expected": {
|
||||
"intent": "prior_auth_confusion",
|
||||
"required_entities": {
|
||||
"payer": "Blue Cross",
|
||||
"member_id": "BCX-9017710"
|
||||
},
|
||||
"required_tool_calls": [
|
||||
"insurance_eligibility_lookup",
|
||||
"appointment_referral_status_lookup"
|
||||
],
|
||||
"required_resolution_elements": [
|
||||
"prior authorization",
|
||||
"recommended next step"
|
||||
],
|
||||
"expected_payer": "Blue Cross"
|
||||
},
|
||||
"gold": {
|
||||
"expected_next_step": "Route to auth queue and share referral pending status with patient."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"scenario_id": "prior_auth_confusion_ct",
|
||||
"description": "Caller is confused about whether CT angiogram needs prior auth and what intake should do.",
|
||||
"transcript": "This is Victor Chen. I was told to schedule a CT angiogram, but another office said prior authorization is missing.\nMy insurance is UnitedHealthcare and I think my ID is UHC-771032.\nI need to know if I can move forward or if you need more information.",
|
||||
"patient_metadata": {
|
||||
"patient_id": "PAT-1002"
|
||||
},
|
||||
"followup_qa": {
|
||||
"date of birth": "09/03/1990",
|
||||
"procedure or visit type": "CT angiogram",
|
||||
"payer": "UnitedHealthcare",
|
||||
"member ID": "UHC-771032"
|
||||
},
|
||||
"expected": {
|
||||
"intent": "prior_auth_confusion",
|
||||
"required_entities": {
|
||||
"payer": "UnitedHealthcare",
|
||||
"member_id": "UHC-771032"
|
||||
},
|
||||
"required_tool_calls": [
|
||||
"insurance_eligibility_lookup"
|
||||
],
|
||||
"required_resolution_elements": [
|
||||
"prior authorization",
|
||||
"recommended next step"
|
||||
],
|
||||
"expected_payer": "UnitedHealthcare"
|
||||
},
|
||||
"gold": {
|
||||
"expected_next_step": "Route to utilization review with CT angiogram authorization packet."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"scenario_id": "referral_status_check",
|
||||
"description": "Patient asks for specialist referral status with known referral ID.",
|
||||
"transcript": "Hi, this is Nora Patel. I am checking on referral number REF-88421 for cardiology with Dr. Ramos.\nCan you tell me if it has been approved and how many visits I still have?",
|
||||
"patient_metadata": {
|
||||
"patient_id": "PAT-1003"
|
||||
},
|
||||
"followup_qa": {
|
||||
"referral number": "REF-88421",
|
||||
"provider": "Dr. Ramos"
|
||||
},
|
||||
"expected": {
|
||||
"intent": "referral_status_question",
|
||||
"required_entities": {
|
||||
"referral_id": "REF-88421"
|
||||
},
|
||||
"required_tool_calls": [
|
||||
"appointment_referral_status_lookup"
|
||||
],
|
||||
"required_resolution_elements": [
|
||||
"referral",
|
||||
"remaining authorized visits"
|
||||
],
|
||||
"expected_payer": "Aetna"
|
||||
},
|
||||
"gold": {
|
||||
"expected_next_step": "Notify patient referral is approved and proceed to specialist scheduling."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
_DEMO_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_DEMO_DIR.parents[2]))
|
||||
sys.path.insert(0, str(_DEMO_DIR))
|
||||
|
||||
from examples.auto_mode import confirm_with_fallback, input_with_fallback # noqa: E402
|
||||
from examples.sandbox.healthcare_support.data import ( # noqa: E402
|
||||
HealthcareSupportDataStore,
|
||||
load_root_env,
|
||||
)
|
||||
from examples.sandbox.healthcare_support.models import ScenarioCase # noqa: E402
|
||||
from examples.sandbox.healthcare_support.tools import HealthcareSupportContext # noqa: E402
|
||||
from examples.sandbox.healthcare_support.workflow import ( # noqa: E402
|
||||
CACHE_ROOT,
|
||||
DEFAULT_SESSION_ID,
|
||||
SESSION_DB_PATH,
|
||||
build_context,
|
||||
run_healthcare_support_workflow,
|
||||
)
|
||||
|
||||
DEFAULT_SCENARIO_ID = "eligibility_verification_basic"
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the healthcare support Agents SDK demo from the command line.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
dest="scenario_id",
|
||||
default=None,
|
||||
help="Scenario ID to run. If omitted, the CLI asks interactively.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-scenarios",
|
||||
action="store_true",
|
||||
help="Print the built-in scenario IDs and exit.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset-memory",
|
||||
action="store_true",
|
||||
help="Delete the shared SQLite session database before running.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _print_scenarios(store: HealthcareSupportDataStore) -> None:
|
||||
print("Available scenarios:\n")
|
||||
for scenario_id in store.list_scenario_ids():
|
||||
scenario = store.get_scenario(scenario_id)
|
||||
print(f"- {scenario.scenario_id}")
|
||||
print(f" {scenario.description}")
|
||||
|
||||
|
||||
def _pick_scenario(store: HealthcareSupportDataStore, requested_id: str | None) -> ScenarioCase:
|
||||
if requested_id:
|
||||
return store.get_scenario(requested_id)
|
||||
|
||||
scenario_id = input_with_fallback(
|
||||
"Enter a scenario ID: ",
|
||||
DEFAULT_SCENARIO_ID,
|
||||
).strip()
|
||||
if not scenario_id:
|
||||
scenario_id = DEFAULT_SCENARIO_ID
|
||||
return store.get_scenario(scenario_id)
|
||||
|
||||
|
||||
async def _approval_handler(request: dict[str, Any]) -> bool:
|
||||
print("\nHuman approval requested")
|
||||
print(f"Agent: {request.get('agent', 'unknown')}")
|
||||
print(f"Tool: {request.get('tool', 'route_to_human_queue')}")
|
||||
print(json.dumps(request.get("arguments", {}), indent=2))
|
||||
return confirm_with_fallback("Approve handoff to a human queue? [y/N]: ", True)
|
||||
|
||||
|
||||
def _print_run_header(*, scenario: ScenarioCase, context: HealthcareSupportContext) -> None:
|
||||
print("\n" + "=" * 80)
|
||||
print("Healthcare Support Agents SDK Demo")
|
||||
print(f"Scenario: {scenario.scenario_id}")
|
||||
print(f"Description: {scenario.description}")
|
||||
print(f"SQLite memory session: {context.session_id}")
|
||||
print("\nCustomer transcript:\n")
|
||||
print(scenario.transcript)
|
||||
|
||||
|
||||
def _print_run_result(payload: dict[str, Any]) -> None:
|
||||
print("\nTrace URL:")
|
||||
print(payload["trace_url"])
|
||||
|
||||
print("\nPatient-facing response:\n")
|
||||
print(payload["resolution"]["patient_facing_response"])
|
||||
|
||||
print("\nInternal summary:")
|
||||
print(payload["resolution"]["internal_summary"])
|
||||
|
||||
print("\nNext step:")
|
||||
print(payload["resolution"]["next_step"])
|
||||
|
||||
if payload["resolution"].get("handoff_id"):
|
||||
print("\nHuman handoff:")
|
||||
print(payload["resolution"]["handoff_id"])
|
||||
|
||||
print("\nGenerated sandbox artifacts:")
|
||||
for artifact in payload.get("artifacts", []):
|
||||
print(f"- {artifact['path']}")
|
||||
|
||||
print("\nMemory recap:")
|
||||
print(json.dumps(payload["memory_recap"], indent=2))
|
||||
|
||||
print(f"\nSession memory items: {payload['session_memory_items']}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_root_env()
|
||||
args = _build_parser().parse_args()
|
||||
store = HealthcareSupportDataStore.load()
|
||||
|
||||
if args.list_scenarios:
|
||||
_print_scenarios(store)
|
||||
return
|
||||
|
||||
if args.reset_memory and SESSION_DB_PATH.exists():
|
||||
SESSION_DB_PATH.unlink()
|
||||
|
||||
scenario = _pick_scenario(store, args.scenario_id)
|
||||
context = build_context(
|
||||
store=store,
|
||||
scenario_id=scenario.scenario_id,
|
||||
session_id=DEFAULT_SESSION_ID,
|
||||
)
|
||||
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_print_run_header(scenario=scenario, context=context)
|
||||
payload = await run_healthcare_support_workflow(
|
||||
context=context,
|
||||
scenario_id=scenario.scenario_id,
|
||||
approval_handler=_approval_handler,
|
||||
)
|
||||
_print_run_result(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
IntentName = Literal[
|
||||
"eligibility_verification",
|
||||
"prior_auth_confusion",
|
||||
"referral_status_question",
|
||||
"billing_coverage_clarification",
|
||||
"general_intake",
|
||||
]
|
||||
|
||||
|
||||
class ScenarioExpectation(BaseModel):
|
||||
intent: IntentName
|
||||
required_entities: dict[str, str] = Field(default_factory=dict)
|
||||
required_tool_calls: list[str] = Field(default_factory=list)
|
||||
required_resolution_elements: list[str] = Field(default_factory=list)
|
||||
expected_payer: str | None = None
|
||||
|
||||
|
||||
class ScenarioCase(BaseModel):
|
||||
scenario_id: str
|
||||
description: str
|
||||
transcript: str
|
||||
patient_metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
followup_qa: dict[str, str] = Field(default_factory=dict)
|
||||
expected: ScenarioExpectation
|
||||
gold: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KnowledgeSnippet(BaseModel):
|
||||
document_id: str
|
||||
title: str
|
||||
chunk_id: str
|
||||
score: float
|
||||
snippet: str
|
||||
matched_terms: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BenefitReview(BaseModel):
|
||||
patient_name: str
|
||||
patient_id: str
|
||||
payer: str
|
||||
member_id: str
|
||||
eligibility_status: str
|
||||
plan_summary: str
|
||||
referral_status: str
|
||||
prior_auth_recommended: bool
|
||||
recommended_queue: str
|
||||
summary: str
|
||||
|
||||
|
||||
class SandboxPolicyPacket(BaseModel):
|
||||
matched_policy_files: list[str] = Field(default_factory=list)
|
||||
generated_files: list[str] = Field(default_factory=list)
|
||||
shell_commands: list[str] = Field(default_factory=list)
|
||||
policy_summary: str
|
||||
human_review_recommended: bool
|
||||
|
||||
|
||||
class CaseResolution(BaseModel):
|
||||
scenario_id: str
|
||||
intent: IntentName
|
||||
patient_name: str
|
||||
benefits_summary: str
|
||||
policy_summary: str
|
||||
next_step: str
|
||||
route_to_human: bool
|
||||
handoff_id: str | None = None
|
||||
generated_files: list[str] = Field(default_factory=list)
|
||||
internal_summary: str
|
||||
patient_facing_response: str
|
||||
|
||||
|
||||
class MemoryRecap(BaseModel):
|
||||
remembered_patient: str | None = None
|
||||
remembered_intent: IntentName | None = None
|
||||
remembered_next_step: str
|
||||
remembered_handoff: str | None = None
|
||||
remembered_files: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Auth Review Queue Routing
|
||||
|
||||
- Route to auth-review-queue when prior authorization is required, likely required, or blocked by missing CPT/diagnosis details.
|
||||
- Route to care-team-intake-queue when referral or scheduling data is incomplete but payer auth is not yet indicated.
|
||||
- Route to billing-review-queue only for claim denial, refund, or balance disputes.
|
||||
- High-priority auth review applies when surgery or advanced imaging is expected within 14 days.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Billing After Consult FAQ
|
||||
|
||||
- A consult bill can be generated before imaging or surgery authorization is complete.
|
||||
- Patients often confuse referral approval, prior authorization, and claim adjudication.
|
||||
- Staff should explain that consult billing does not confirm surgery authorization.
|
||||
- If the patient reports a bill plus auth confusion, verify eligibility and route to billing only when the question is about claim denial or patient balance.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Blue Cross Benefits Reference
|
||||
|
||||
- Common PPO orthopedic specialist copays range from $40 to $75 depending on employer group.
|
||||
- Deductible and coinsurance still apply to imaging and outpatient surgery.
|
||||
- Benefit verification should capture specialist copay, deductible remaining, and coinsurance.
|
||||
- Benefits data should be summarized separately from authorization status.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Blue Cross PPO Prior Authorization
|
||||
|
||||
- PPO members require prior authorization for inpatient surgery, outpatient surgery over $1,500, and advanced imaging tied to surgical planning.
|
||||
- Knee surgery consults do not require prior authorization by themselves.
|
||||
- MRI or CT imaging ordered after the consult may require prior authorization if performed at a hospital outpatient department.
|
||||
- If referral status is pending, route to auth review before scheduling imaging.
|
||||
- Required fields: member ID, date of birth, ordering provider, CPT code, diagnosis code.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Blue Cross Referral Rules
|
||||
|
||||
- PPO plans do not usually require a PCP referral for orthopedic consults.
|
||||
- Some employer groups still require a referral number for specialist scheduling.
|
||||
- If a referral exists but is pending, staff should verify status before confirming downstream imaging or surgery appointments.
|
||||
- Pending referrals should be routed to the care-team intake queue or auth-review queue depending on whether authorization is also required.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Commercial Eligibility Checklist
|
||||
|
||||
- Verify payer name, member ID, date of birth, and plan status.
|
||||
- Confirm effective date, termination date, copay, deductible, and coinsurance.
|
||||
- If payer name is ambiguous, use member ID and DOB to identify the most likely eligibility match.
|
||||
- Eligibility verification does not replace prior authorization review.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Human Escalation Policy
|
||||
|
||||
- Escalate to a human when payer is ambiguous, prior authorization is likely, referral is pending, or procedure coding is incomplete.
|
||||
- Escalate when patient asks for next steps and multiple operational dependencies are unresolved.
|
||||
- Human queue payloads should include patient summary, payer, member ID, referral ID, requested service, and missing information.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Knee Surgery Medical Necessity
|
||||
|
||||
- Surgical review packets should include consult notes, imaging results, diagnosis, failed conservative treatment, and requested CPT code.
|
||||
- Missing imaging results are a common reason for delayed authorization.
|
||||
- If the patient has a consult but no final procedure code, route to human review for packet completion before payer submission.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Orthopedic Imaging Policy
|
||||
|
||||
- X-ray does not require prior authorization for most commercial plans.
|
||||
- MRI of knee without contrast often requires prior authorization when ordered before surgery.
|
||||
- CT lower extremity may require prior authorization when tied to operative planning.
|
||||
- Imaging requests should include laterality, diagnosis code, and conservative treatment history when available.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Outbound Fax Packet Requirements
|
||||
|
||||
- Prior auth packets should include cover sheet, demographics, insurance card data, consult notes, imaging reports, and requested CPT/ICD-10 codes.
|
||||
- If any required artifact is missing, create a missing-items checklist before faxing.
|
||||
- Human review is required before outbound fax when packet data is incomplete or referral status is pending.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Patient Messaging Guidelines
|
||||
|
||||
- Use plain language and separate what is verified from what is still under review.
|
||||
- Do not tell a patient that surgery is approved unless payer authorization is confirmed.
|
||||
- If referral is pending, say that the referral is still being reviewed and that the care team is checking whether payer authorization is also needed.
|
||||
- Provide one clear next step and one expected owner queue.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Referral Pending SOP
|
||||
|
||||
- Confirm referral ID, patient identity, and rendering specialist before escalation.
|
||||
- If referral status is pending for more than two business days, send to care-team intake queue.
|
||||
- If referral is pending and prior authorization is also likely, send to auth-review queue with a note that referral clearance is still outstanding.
|
||||
- Patient messaging should distinguish referral review from payer authorization.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Scheduling Hold Policy
|
||||
|
||||
- Do not schedule surgery until required payer authorization is approved.
|
||||
- Imaging may be tentatively scheduled only when policy allows no-auth outpatient imaging.
|
||||
- If referral or authorization is pending, place a scheduling hold and notify the patient of the review owner.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: prior-auth-packet-builder
|
||||
description: Build a concise prior authorization packet from local case files and payer policy docs.
|
||||
---
|
||||
|
||||
# Prior Auth Packet Builder
|
||||
|
||||
Use this skill when a case requires prior authorization review, referral validation, imaging review, or payer-specific policy checks.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect `case/scenario.json` and `case/transcript.txt`.
|
||||
2. Use `rg` against `policies/` to find payer, prior auth, referral, imaging, and PPO guidance.
|
||||
3. Read only the most relevant policy files.
|
||||
4. Create `output/policy_findings.md` with:
|
||||
- case summary
|
||||
- matched policy files
|
||||
- prior auth determination
|
||||
- referral determination
|
||||
- missing information
|
||||
5. Create `output/human_review_checklist.md` with:
|
||||
- what a human reviewer should verify
|
||||
- what to tell the patient
|
||||
- what queue should own the case
|
||||
|
||||
## Rules
|
||||
|
||||
- Use targeted `rg` searches over broad file reads.
|
||||
- Only cite policy files you actually inspected.
|
||||
- Keep outputs concise and operational.
|
||||
- If referral status is pending and prior auth is unclear, recommend human review.
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from agents import Agent, AgentOutputSchema, ModelSettings, Tool
|
||||
from agents.sandbox import SandboxAgent
|
||||
from agents.sandbox.capabilities import Filesystem, LocalDirLazySkillSource, Shell, Skills
|
||||
from agents.sandbox.entries import LocalDir
|
||||
from examples.sandbox.healthcare_support.models import (
|
||||
BenefitReview,
|
||||
CaseResolution,
|
||||
MemoryRecap,
|
||||
SandboxPolicyPacket,
|
||||
)
|
||||
from examples.sandbox.healthcare_support.tools import (
|
||||
HealthcareSupportContext,
|
||||
lookup_insurance_eligibility,
|
||||
lookup_patient,
|
||||
lookup_referral_status,
|
||||
route_to_human_queue,
|
||||
)
|
||||
|
||||
BENEFITS_PROMPT = """
|
||||
You are a healthcare benefits specialist in a synthetic support workflow.
|
||||
|
||||
Use the available lookup tools to verify patient, eligibility, and referral details, then return a
|
||||
structured benefits review.
|
||||
|
||||
Rules:
|
||||
1. Call `patient_info_lookup` first when you have a patient ID, phone number, or patient name.
|
||||
2. Call `insurance_eligibility_lookup` when payer, member ID, or date of birth is available.
|
||||
3. Call `appointment_referral_status_lookup` when referral ID or patient ID is available.
|
||||
4. Recommend prior-auth review only when the case involves imaging, surgery, a pending referral, or
|
||||
policy-specific authorization language.
|
||||
5. Set `recommended_queue` to one of `care-team-intake-queue`, `auth-review-queue`, or
|
||||
`billing-review-queue`.
|
||||
6. Keep the summary concise and grounded in tool output.
|
||||
""".strip()
|
||||
|
||||
|
||||
POLICY_SANDBOX_PROMPT = """
|
||||
You are a policy packet specialist running inside a sandbox workspace.
|
||||
|
||||
Inspect the case files and local policy library, generate concise markdown artifacts in `output/`,
|
||||
and return a structured packet summary.
|
||||
|
||||
You must:
|
||||
1. Load and use the `prior-auth-packet-builder` skill.
|
||||
2. Inspect the workspace with shell commands before writing anything.
|
||||
3. Use `rg` against `policies/` for prior-auth, imaging, referral, billing, PPO, and Blue Cross
|
||||
policy guidance.
|
||||
4. Create `output/policy_findings.md` with the most relevant policy guidance.
|
||||
5. Create `output/human_review_checklist.md` with a short checklist for a human reviewer.
|
||||
6. Set `human_review_recommended=true` only when the policy search or case input shows missing
|
||||
authorization/referral details that should be reviewed by a human before responding.
|
||||
7. Include the exact shell commands you ran in `shell_commands`.
|
||||
8. Return only facts grounded in the files you inspected.
|
||||
""".strip()
|
||||
|
||||
|
||||
ORCHESTRATOR_PROMPT = """
|
||||
You are a healthcare support orchestrator.
|
||||
|
||||
Coordinate a synthetic support case by combining a benefits review, a sandbox policy packet review,
|
||||
and a human handoff only when the case genuinely needs it.
|
||||
|
||||
Rules:
|
||||
1. Always call `benefits_review` first.
|
||||
2. Always call `sandbox_policy_packet` second.
|
||||
3. For this demo, call `route_to_human_queue` only for the
|
||||
`messy_ambiguous_knee_case` scenario when the sandbox packet recommends human review.
|
||||
4. Do not escalate the other four scenarios; answer those directly from the benefits and sandbox
|
||||
outputs.
|
||||
5. If you call `route_to_human_queue`, include the returned `handoff_id` and set
|
||||
`route_to_human=true`.
|
||||
6. Produce a clear patient-facing response, a short internal summary, and a concrete next step.
|
||||
7. Use only facts from the tool outputs and the supplied scenario payload.
|
||||
""".strip()
|
||||
|
||||
|
||||
MEMORY_PROMPT = """
|
||||
Summarize what you remember from this SQLite-backed session about the prior patient support cases.
|
||||
|
||||
Include the most recently remembered patient, intent, handoff status, generated files, and next
|
||||
step. Do not call tools.
|
||||
""".strip()
|
||||
|
||||
|
||||
benefits_agent = Agent[HealthcareSupportContext](
|
||||
name="HealthcareBenefitsAgent",
|
||||
model="gpt-5.6-sol",
|
||||
instructions=BENEFITS_PROMPT,
|
||||
model_settings=ModelSettings(reasoning=Reasoning(effort="low"), verbosity="low"),
|
||||
tools=[
|
||||
lookup_patient,
|
||||
lookup_insurance_eligibility,
|
||||
lookup_referral_status,
|
||||
],
|
||||
output_type=AgentOutputSchema(BenefitReview, strict_json_schema=False),
|
||||
)
|
||||
|
||||
|
||||
def build_policy_sandbox_agent(*, skills_root: Path) -> SandboxAgent[HealthcareSupportContext]:
|
||||
return SandboxAgent[HealthcareSupportContext](
|
||||
name="HealthcarePolicySandboxAgent",
|
||||
model="gpt-5.6-sol",
|
||||
instructions=(
|
||||
POLICY_SANDBOX_PROMPT + "\n\n"
|
||||
"Use `load_skill` before reading the skill file. Use `exec_command` with `pwd`, "
|
||||
"`ls`, `cat`, and `rg` to inspect the sandbox workspace. Use `apply_patch` to create "
|
||||
"`output/policy_findings.md` and `output/human_review_checklist.md`."
|
||||
),
|
||||
capabilities=[
|
||||
Shell(),
|
||||
Filesystem(),
|
||||
Skills(
|
||||
lazy_from=LocalDirLazySkillSource(
|
||||
# This is a host path read by the SDK process.
|
||||
# Requested skills are copied into `skills_path` in the sandbox.
|
||||
source=LocalDir(src=skills_root),
|
||||
)
|
||||
),
|
||||
],
|
||||
model_settings=ModelSettings(
|
||||
reasoning=Reasoning(effort="low"),
|
||||
verbosity="low",
|
||||
tool_choice="required",
|
||||
),
|
||||
output_type=AgentOutputSchema(SandboxPolicyPacket, strict_json_schema=False),
|
||||
)
|
||||
|
||||
|
||||
def build_orchestrator(*, sandbox_policy_tool: Tool) -> Agent[HealthcareSupportContext]:
|
||||
return Agent[HealthcareSupportContext](
|
||||
name="HealthcareSupportOrchestrator",
|
||||
model="gpt-5.6-sol",
|
||||
instructions=ORCHESTRATOR_PROMPT,
|
||||
model_settings=ModelSettings(
|
||||
reasoning=Reasoning(effort="low"),
|
||||
verbosity="low",
|
||||
),
|
||||
tools=[
|
||||
benefits_agent.as_tool(
|
||||
tool_name="benefits_review",
|
||||
tool_description="Review patient eligibility, benefits, and referral status.",
|
||||
),
|
||||
sandbox_policy_tool,
|
||||
route_to_human_queue,
|
||||
],
|
||||
output_type=AgentOutputSchema(CaseResolution, strict_json_schema=False),
|
||||
)
|
||||
|
||||
|
||||
memory_recap_agent = Agent[HealthcareSupportContext](
|
||||
name="HealthcareSupportMemoryAgent",
|
||||
model="gpt-5.6-sol",
|
||||
instructions=MEMORY_PROMPT,
|
||||
model_settings=ModelSettings(reasoning=Reasoning(effort="low"), verbosity="low"),
|
||||
output_type=AgentOutputSchema(MemoryRecap, strict_json_schema=False),
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
from examples.sandbox.healthcare_support.data import HealthcareSupportDataStore
|
||||
from examples.sandbox.healthcare_support.models import ScenarioCase
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthcareSupportContext:
|
||||
store: HealthcareSupportDataStore
|
||||
scenario: ScenarioCase
|
||||
session_id: str = ""
|
||||
human_handoffs: list[dict[str, Any]] = field(default_factory=list)
|
||||
human_handoff_approved: bool = False
|
||||
emit_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None
|
||||
|
||||
async def emit(self, event_name: str, **payload: Any) -> None:
|
||||
if self.emit_event is None:
|
||||
return
|
||||
await self.emit_event(
|
||||
{
|
||||
"type": "workflow_event",
|
||||
"event": event_name,
|
||||
**payload,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@function_tool(name_override="patient_info_lookup")
|
||||
def lookup_patient(
|
||||
context: RunContextWrapper[HealthcareSupportContext],
|
||||
patient_id: str | None = None,
|
||||
phone: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Look up a synthetic patient profile by patient ID, phone, or name."""
|
||||
return context.context.store.lookup_patient(
|
||||
patient_id=patient_id,
|
||||
phone=phone,
|
||||
name=name,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(name_override="insurance_eligibility_lookup")
|
||||
def lookup_insurance_eligibility(
|
||||
context: RunContextWrapper[HealthcareSupportContext],
|
||||
payer: str | None = None,
|
||||
member_id: str | None = None,
|
||||
dob: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Look up synthetic insurance eligibility by payer, member ID, and DOB."""
|
||||
return context.context.store.lookup_eligibility(
|
||||
payer=payer,
|
||||
member_id=member_id,
|
||||
dob=dob,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(name_override="appointment_referral_status_lookup")
|
||||
def lookup_referral_status(
|
||||
context: RunContextWrapper[HealthcareSupportContext],
|
||||
referral_id: str | None = None,
|
||||
patient_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Look up synthetic referral status by referral ID or patient ID."""
|
||||
return context.context.store.lookup_referral(
|
||||
referral_id=referral_id,
|
||||
patient_id=patient_id,
|
||||
)
|
||||
|
||||
|
||||
async def _needs_human_approval(
|
||||
context: RunContextWrapper[HealthcareSupportContext],
|
||||
_params: dict[str, Any],
|
||||
_call_id: str,
|
||||
) -> bool:
|
||||
return not context.context.human_handoff_approved
|
||||
|
||||
|
||||
@function_tool(name_override="route_to_human_queue", needs_approval=_needs_human_approval)
|
||||
def route_to_human_queue(
|
||||
context: RunContextWrapper[HealthcareSupportContext],
|
||||
queue: str,
|
||||
priority: str,
|
||||
reason: str,
|
||||
summary: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Route a synthetic case to a human queue after explicit approval."""
|
||||
payload = {
|
||||
"queue": queue,
|
||||
"priority": priority,
|
||||
"reason": reason,
|
||||
"summary": summary,
|
||||
"scenario_id": context.context.scenario.scenario_id,
|
||||
}
|
||||
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:12]
|
||||
result = {
|
||||
"status": "queued",
|
||||
"handoff_id": f"HUMAN-{digest.upper()}",
|
||||
"queue": queue,
|
||||
"priority": priority,
|
||||
"reason": reason,
|
||||
"summary": summary,
|
||||
}
|
||||
context.context.human_handoffs.append({"payload": payload, "result": result})
|
||||
return result
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agents import (
|
||||
Agent,
|
||||
AgentHookContext,
|
||||
RunContextWrapper,
|
||||
RunHooks,
|
||||
Runner,
|
||||
SQLiteSession,
|
||||
Tool,
|
||||
gen_trace_id,
|
||||
trace,
|
||||
)
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxPathGrant, SandboxRunConfig
|
||||
from agents.sandbox.entries import Dir, File, LocalDir
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
from agents.tool_context import ToolContext
|
||||
from examples.sandbox.healthcare_support.data import HealthcareSupportDataStore
|
||||
from examples.sandbox.healthcare_support.models import (
|
||||
CaseResolution,
|
||||
MemoryRecap,
|
||||
ScenarioCase,
|
||||
)
|
||||
from examples.sandbox.healthcare_support.support_agents import (
|
||||
build_orchestrator,
|
||||
build_policy_sandbox_agent,
|
||||
memory_recap_agent,
|
||||
)
|
||||
from examples.sandbox.healthcare_support.tools import HealthcareSupportContext
|
||||
|
||||
EXAMPLE_ROOT = Path(__file__).resolve().parent
|
||||
POLICIES_ROOT = EXAMPLE_ROOT / "policies"
|
||||
SKILLS_ROOT = EXAMPLE_ROOT / "skills"
|
||||
SDK_ROOT = EXAMPLE_ROOT.parents[2]
|
||||
CACHE_ROOT = SDK_ROOT / ".cache" / "healthcare_support"
|
||||
SESSION_DB_PATH = CACHE_ROOT / "sessions.db"
|
||||
DEFAULT_SESSION_ID = "healthcare-support-demo-memory"
|
||||
|
||||
ApprovalHandler = Callable[[dict[str, Any]], Awaitable[bool]]
|
||||
|
||||
|
||||
class WorkflowHooks(RunHooks[HealthcareSupportContext]):
|
||||
async def on_agent_start(
|
||||
self,
|
||||
context: AgentHookContext[HealthcareSupportContext],
|
||||
agent: Agent[HealthcareSupportContext],
|
||||
) -> None:
|
||||
await context.context.emit("agent_start", agent=agent.name)
|
||||
|
||||
async def on_agent_end(
|
||||
self,
|
||||
context: RunContextWrapper[HealthcareSupportContext],
|
||||
agent: Agent[HealthcareSupportContext],
|
||||
output: Any,
|
||||
) -> None:
|
||||
await context.context.emit(
|
||||
"agent_end",
|
||||
agent=agent.name,
|
||||
output=_to_jsonable(output),
|
||||
)
|
||||
|
||||
async def on_tool_start(
|
||||
self,
|
||||
context: RunContextWrapper[HealthcareSupportContext],
|
||||
agent: Agent[HealthcareSupportContext],
|
||||
tool: Tool,
|
||||
) -> None:
|
||||
tool_context = cast(ToolContext[HealthcareSupportContext], context)
|
||||
await context.context.emit(
|
||||
"tool_start",
|
||||
agent=agent.name,
|
||||
tool=tool.name,
|
||||
call_id=tool_context.tool_call_id,
|
||||
arguments=tool_context.tool_arguments,
|
||||
)
|
||||
|
||||
async def on_tool_end(
|
||||
self,
|
||||
context: RunContextWrapper[HealthcareSupportContext],
|
||||
agent: Agent[HealthcareSupportContext],
|
||||
tool: Tool,
|
||||
result: object,
|
||||
) -> None:
|
||||
tool_context = cast(ToolContext[HealthcareSupportContext], context)
|
||||
await context.context.emit(
|
||||
"tool_end",
|
||||
agent=agent.name,
|
||||
tool=tool.name,
|
||||
call_id=tool_context.tool_call_id,
|
||||
output=_to_jsonable(result),
|
||||
)
|
||||
|
||||
|
||||
def _to_jsonable(value: Any) -> Any:
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, dict | list | str | int | float | bool) or value is None:
|
||||
return value
|
||||
try:
|
||||
return json.loads(json.dumps(value, default=str))
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def build_context(
|
||||
*,
|
||||
store: HealthcareSupportDataStore,
|
||||
scenario_id: str = "eligibility_verification_basic",
|
||||
session_id: str = DEFAULT_SESSION_ID,
|
||||
emit_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
) -> HealthcareSupportContext:
|
||||
return HealthcareSupportContext(
|
||||
store=store,
|
||||
scenario=store.get_scenario(scenario_id),
|
||||
session_id=session_id,
|
||||
emit_event=emit_event,
|
||||
)
|
||||
|
||||
|
||||
def _build_manifest(scenario: ScenarioCase) -> Manifest:
|
||||
return Manifest(
|
||||
extra_path_grants=(
|
||||
SandboxPathGrant(path=str(POLICIES_ROOT), read_only=True),
|
||||
SandboxPathGrant(path=str(SKILLS_ROOT), read_only=True),
|
||||
),
|
||||
entries={
|
||||
"case": Dir(
|
||||
children={
|
||||
"scenario.json": File(
|
||||
content=json.dumps(scenario.model_dump(mode="json"), indent=2).encode(
|
||||
"utf-8"
|
||||
)
|
||||
),
|
||||
"transcript.txt": File(content=scenario.transcript.encode("utf-8")),
|
||||
},
|
||||
description="Synthetic support request and scenario metadata.",
|
||||
),
|
||||
"policies": LocalDir(
|
||||
src=POLICIES_ROOT,
|
||||
description="Local healthcare policy and workflow documents.",
|
||||
),
|
||||
"output": Dir(description="Generated support artifacts for this case."),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _structured_tool_output_extractor(result: Any) -> str:
|
||||
final_output = result.final_output
|
||||
if isinstance(final_output, BaseModel):
|
||||
return json.dumps(final_output.model_dump(mode="json"), sort_keys=True)
|
||||
return str(final_output)
|
||||
|
||||
|
||||
def _fallback_artifacts(*, scenario: ScenarioCase, resolution: CaseResolution) -> dict[str, str]:
|
||||
policy_doc = f"""# Policy Findings
|
||||
|
||||
## Case
|
||||
{scenario.description}
|
||||
|
||||
## Policy summary
|
||||
{resolution.policy_summary}
|
||||
|
||||
## Next step
|
||||
{resolution.next_step}
|
||||
"""
|
||||
checklist_doc = f"""# Human Review Checklist
|
||||
|
||||
- Confirm whether the request needs prior authorization for this service and payer.
|
||||
- Verify referral state and any missing clinical or billing identifiers.
|
||||
- Use this internal summary: {resolution.internal_summary}
|
||||
- Patient-facing response: {resolution.patient_facing_response}
|
||||
"""
|
||||
return {
|
||||
"policy_findings.md": policy_doc,
|
||||
"human_review_checklist.md": checklist_doc,
|
||||
}
|
||||
|
||||
|
||||
async def _copy_output_files(
|
||||
*,
|
||||
sandbox: Any,
|
||||
scenario: ScenarioCase,
|
||||
resolution: CaseResolution,
|
||||
) -> list[dict[str, str]]:
|
||||
scenario_id = scenario.scenario_id
|
||||
destination_root = CACHE_ROOT / "output" / scenario_id
|
||||
destination_root.mkdir(parents=True, exist_ok=True)
|
||||
copied_by_name: dict[str, dict[str, str]] = {}
|
||||
|
||||
for entry in await sandbox.ls("output"):
|
||||
entry_path = Path(entry.path)
|
||||
if entry.is_dir():
|
||||
continue
|
||||
|
||||
handle = await sandbox.read(entry_path)
|
||||
try:
|
||||
payload = handle.read()
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
local_path = destination_root / entry_path.name
|
||||
if isinstance(payload, str):
|
||||
content = payload
|
||||
local_path.write_text(content, encoding="utf-8")
|
||||
else:
|
||||
content = bytes(payload).decode("utf-8", errors="replace")
|
||||
local_path.write_text(content, encoding="utf-8")
|
||||
|
||||
copied_by_name[entry_path.name] = {
|
||||
"name": entry_path.name,
|
||||
"path": str(local_path),
|
||||
"content": content,
|
||||
}
|
||||
|
||||
for filename, content in _fallback_artifacts(
|
||||
scenario=scenario,
|
||||
resolution=resolution,
|
||||
).items():
|
||||
if filename in copied_by_name:
|
||||
continue
|
||||
local_path = destination_root / filename
|
||||
local_path.write_text(content, encoding="utf-8")
|
||||
copied_by_name[filename] = {
|
||||
"name": filename,
|
||||
"path": str(local_path),
|
||||
"content": content,
|
||||
}
|
||||
|
||||
return [copied_by_name[name] for name in sorted(copied_by_name)]
|
||||
|
||||
|
||||
async def _resolve_interruptions(
|
||||
*,
|
||||
result: Any,
|
||||
orchestrator: Agent[HealthcareSupportContext],
|
||||
context: HealthcareSupportContext,
|
||||
conversation_session: SQLiteSession,
|
||||
hooks: WorkflowHooks,
|
||||
approval_handler: ApprovalHandler | None,
|
||||
) -> Any:
|
||||
approval_round = 0
|
||||
while result.interruptions:
|
||||
approval_round += 1
|
||||
if approval_round > 5:
|
||||
raise RuntimeError("Exceeded 5 approval rounds while resuming the workflow.")
|
||||
|
||||
state = result.to_state()
|
||||
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
state_payload = state.to_json(
|
||||
context_serializer=lambda value: {
|
||||
"scenario_id": value.scenario.scenario_id,
|
||||
"session_id": value.session_id,
|
||||
"human_handoffs": value.human_handoffs,
|
||||
}
|
||||
)
|
||||
(CACHE_ROOT / "pending_state.json").write_text(
|
||||
json.dumps(state_payload, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
for interruption in result.interruptions:
|
||||
request = {
|
||||
"agent": interruption.agent.name,
|
||||
"tool": interruption.name,
|
||||
"arguments": _to_jsonable(interruption.arguments),
|
||||
}
|
||||
await context.emit("human_approval_requested", request=request)
|
||||
approved = True if approval_handler is None else await approval_handler(request)
|
||||
|
||||
if approved:
|
||||
context.human_handoff_approved = True
|
||||
state.approve(interruption, always_approve=False)
|
||||
await context.emit("human_approval_resolved", approved=True, request=request)
|
||||
else:
|
||||
context.human_handoff_approved = False
|
||||
state.reject(interruption)
|
||||
await context.emit("human_approval_resolved", approved=False, request=request)
|
||||
|
||||
result = await Runner.run(
|
||||
orchestrator,
|
||||
state,
|
||||
session=conversation_session,
|
||||
hooks=hooks,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _workflow_prompt(scenario: ScenarioCase) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"scenario_id": scenario.scenario_id,
|
||||
"description": scenario.description,
|
||||
"transcript": scenario.transcript,
|
||||
"patient_metadata": scenario.patient_metadata,
|
||||
"followup_answers": scenario.followup_qa,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
async def run_healthcare_support_workflow(
|
||||
*,
|
||||
context: HealthcareSupportContext,
|
||||
scenario_id: str,
|
||||
approval_handler: ApprovalHandler | None = None,
|
||||
) -> dict[str, Any]:
|
||||
scenario = context.store.get_scenario(scenario_id)
|
||||
context.scenario = scenario
|
||||
context.human_handoffs.clear()
|
||||
context.human_handoff_approved = False
|
||||
|
||||
await context.emit(
|
||||
"scenario_loaded",
|
||||
scenario_id=scenario.scenario_id,
|
||||
description=scenario.description,
|
||||
transcript=scenario.transcript,
|
||||
)
|
||||
|
||||
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
conversation_session = SQLiteSession(
|
||||
session_id=context.session_id or DEFAULT_SESSION_ID, db_path=SESSION_DB_PATH
|
||||
)
|
||||
await context.emit("memory_ready", session_id=conversation_session.session_id)
|
||||
|
||||
hooks = WorkflowHooks()
|
||||
sandbox_client = UnixLocalSandboxClient()
|
||||
sandbox = await sandbox_client.create(manifest=_build_manifest(scenario))
|
||||
await context.emit(
|
||||
"sandbox_ready",
|
||||
backend="unix_local",
|
||||
workspace=["case/scenario.json", "case/transcript.txt", "policies/", "output/"],
|
||||
)
|
||||
|
||||
policy_agent = build_policy_sandbox_agent(skills_root=SKILLS_ROOT)
|
||||
sandbox_policy_tool = policy_agent.as_tool(
|
||||
tool_name="sandbox_policy_packet",
|
||||
tool_description="Inspect policy files in a sandbox and generate support artifacts.",
|
||||
custom_output_extractor=_structured_tool_output_extractor,
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name="Healthcare support sandbox packet",
|
||||
),
|
||||
hooks=hooks,
|
||||
max_turns=20,
|
||||
)
|
||||
orchestrator = build_orchestrator(sandbox_policy_tool=sandbox_policy_tool)
|
||||
trace_id = gen_trace_id()
|
||||
trace_url = f"https://platform.openai.com/traces/trace?trace_id={trace_id}"
|
||||
|
||||
try:
|
||||
async with sandbox:
|
||||
await context.emit("trace_ready", trace_id=trace_id, trace_url=trace_url)
|
||||
with trace(
|
||||
"Healthcare support workflow",
|
||||
trace_id=trace_id,
|
||||
group_id=scenario.scenario_id,
|
||||
):
|
||||
result = await Runner.run(
|
||||
orchestrator,
|
||||
_workflow_prompt(scenario),
|
||||
context=context,
|
||||
session=conversation_session,
|
||||
hooks=hooks,
|
||||
)
|
||||
result = await _resolve_interruptions(
|
||||
result=result,
|
||||
orchestrator=orchestrator,
|
||||
context=context,
|
||||
conversation_session=conversation_session,
|
||||
hooks=hooks,
|
||||
approval_handler=approval_handler,
|
||||
)
|
||||
resolution = result.final_output_as(CaseResolution)
|
||||
|
||||
copied_files = await _copy_output_files(
|
||||
sandbox=sandbox,
|
||||
scenario=scenario,
|
||||
resolution=resolution,
|
||||
)
|
||||
await context.emit("artifacts_ready", files=copied_files)
|
||||
|
||||
memory_result = await Runner.run(
|
||||
memory_recap_agent,
|
||||
(
|
||||
"Summarize what you remember from the session. Include patient, intent, "
|
||||
"handoff state, generated files, and next step."
|
||||
),
|
||||
context=context,
|
||||
session=conversation_session,
|
||||
hooks=hooks,
|
||||
)
|
||||
recap = memory_result.final_output_as(MemoryRecap)
|
||||
|
||||
history_items = await conversation_session.get_items()
|
||||
payload = {
|
||||
"scenario_id": scenario.scenario_id,
|
||||
"description": scenario.description,
|
||||
"transcript": scenario.transcript,
|
||||
"trace_id": trace_id,
|
||||
"trace_url": trace_url,
|
||||
"resolution": resolution.model_dump(mode="json"),
|
||||
"memory_recap": recap.model_dump(mode="json"),
|
||||
"artifacts": copied_files,
|
||||
"session_id": conversation_session.session_id,
|
||||
"session_memory_items": len(history_items),
|
||||
}
|
||||
await context.emit("workflow_complete", payload=payload)
|
||||
return payload
|
||||
finally:
|
||||
await sandbox_client.delete(sandbox)
|
||||
await context.emit("sandbox_stopped", backend="unix_local")
|
||||
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from agents import Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.capabilities import Filesystem, Memory, Shell
|
||||
from agents.sandbox.entries import File
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
DEFAULT_MODEL = "gpt-5.6-sol"
|
||||
FIRST_PROMPT = "Inspect workspace and fix invoice total bug in src/acme_metrics/report.py."
|
||||
SECOND_PROMPT = "Add a regression test for the previous bug you fixed."
|
||||
|
||||
|
||||
def _build_manifest() -> Manifest:
|
||||
return Manifest(
|
||||
entries={
|
||||
"README.md": File(
|
||||
content=(
|
||||
b"# Acme Metrics\n\n"
|
||||
b"Small demo package for validating invoice total formatting.\n"
|
||||
)
|
||||
),
|
||||
"pyproject.toml": File(
|
||||
content=(
|
||||
b"[project]\n"
|
||||
b'name = "acme-metrics"\n'
|
||||
b'version = "0.1.0"\n'
|
||||
b'requires-python = ">=3.10"\n'
|
||||
b"\n"
|
||||
b"[tool.pytest.ini_options]\n"
|
||||
b'pythonpath = ["src"]\n'
|
||||
)
|
||||
),
|
||||
"src/acme_metrics/__init__.py": File(
|
||||
content=b"from .report import format_invoice_total\n"
|
||||
),
|
||||
"src/acme_metrics/report.py": File(
|
||||
content=(
|
||||
b"from __future__ import annotations\n\n"
|
||||
b"def format_invoice_total(subtotal: float, tax_rate: float) -> str:\n"
|
||||
b" total = subtotal + tax_rate\n"
|
||||
b' return f"${total:.2f}"\n'
|
||||
)
|
||||
),
|
||||
"tests/test_report.py": File(
|
||||
content=(
|
||||
b"from acme_metrics import format_invoice_total\n\n\n"
|
||||
b"def test_format_invoice_total_applies_tax_rate() -> None:\n"
|
||||
b' assert format_invoice_total(100.0, 0.075) == "$107.50"\n'
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_agent(*, model: str, manifest: Manifest) -> SandboxAgent:
|
||||
# This one user-facing agent can read existing memory, update stale memory in place, and
|
||||
# generate new background memories when the sandbox session closes.
|
||||
return SandboxAgent(
|
||||
name="Sandbox Memory Demo",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect files before answering, make "
|
||||
"minimal edits, and keep the response concise. "
|
||||
"Use the shell tool to inspect and validate the workspace. Use apply_patch for text "
|
||||
"edits when it is the clearest option. Use a non-login POSIX shell for commands. "
|
||||
"Make one focused pytest attempt; if the local sandbox blocks Python or toolchain "
|
||||
"access, report that validation was blocked and finish instead of retrying repeatedly. "
|
||||
"Do not invent files you did not read."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[
|
||||
# `Memory()` enables both read and generate behavior with live updates on by default.
|
||||
Memory(),
|
||||
Filesystem(),
|
||||
Shell(),
|
||||
],
|
||||
# `Memory()` is the recommended default. If you need to tune the behavior, you can switch
|
||||
# to an explicit config such as:
|
||||
#
|
||||
# Memory(
|
||||
# layout=MemoryLayoutConfig(memories_dir="agent_memory", sessions_dir="agent_sessions"),
|
||||
# read=MemoryReadConfig(live_update=False),
|
||||
# generate=MemoryGenerateConfig(max_raw_memories_for_consolidation=128),
|
||||
# )
|
||||
#
|
||||
# `generate.max_raw_memories_for_consolidation`: cap how many recent raw memories are
|
||||
# considered during consolidation. Older conversation-specific guidance may be removed from
|
||||
# consolidated memory when the cap is exceeded.
|
||||
#
|
||||
# Multi-turn conversations work best when all turns share the same live sandbox session and
|
||||
# an SDK Session. The SDK session_id groups those runs into one memory conversation. Without
|
||||
# an SDK session, sandbox memory falls back to OpenAI conversation_id, then RunConfig
|
||||
# group_id, then one generated memory conversation for each Runner.run().
|
||||
#
|
||||
# `read.live_update=False`: use this when the agent should not repair stale memory during
|
||||
# the run. That can save a few seconds, but stale memory debt can accumulate until a later
|
||||
# consolidation, which may or may not catch the staleness. It also prevents the agent from
|
||||
# updating memory immediately during the run, including when the user explicitly asks it to
|
||||
# remember something new or revise existing memory.
|
||||
#
|
||||
# If you need additional memory-generation guidance, `generate.extra_prompt` is appended to the
|
||||
# built-in memory prompt. Keep it short, ideally a few focused bullets and well under ~5k
|
||||
# tokens, so the model still pays attention to the conversation evidence.
|
||||
#
|
||||
# Memory(
|
||||
# generate=MemoryGenerateConfig(
|
||||
# extra_prompt="Pay extra attention to documenting what bug was fixed and why it happened."
|
||||
# )
|
||||
# )
|
||||
)
|
||||
|
||||
|
||||
def _artifact_paths(
|
||||
*, memories_dir: str = "memories", sessions_dir: str = "sessions"
|
||||
) -> tuple[Path, ...]:
|
||||
return (
|
||||
Path(sessions_dir),
|
||||
Path(memories_dir) / "MEMORY.md",
|
||||
Path(memories_dir) / "memory_summary.md",
|
||||
Path(memories_dir) / "raw_memories.md",
|
||||
Path(memories_dir) / "raw_memories",
|
||||
Path(memories_dir) / "rollout_summaries",
|
||||
)
|
||||
|
||||
|
||||
def _print_memory_tree(workspace_root: Path) -> None:
|
||||
print("\nGenerated memory artifacts:")
|
||||
for relative_path in _artifact_paths():
|
||||
full_path = workspace_root / relative_path
|
||||
if not full_path.exists():
|
||||
print(f"- {relative_path} (missing)")
|
||||
continue
|
||||
|
||||
if full_path.is_dir():
|
||||
print(f"- {relative_path}/")
|
||||
for child in sorted(full_path.iterdir()):
|
||||
print(f" - {relative_path / child.name}")
|
||||
if relative_path == Path("sessions"):
|
||||
contents = child.read_text().rstrip()
|
||||
if not contents:
|
||||
print(" (empty)")
|
||||
else:
|
||||
for line in contents.splitlines():
|
||||
print(f" {line}")
|
||||
continue
|
||||
|
||||
print(f"- {relative_path}")
|
||||
print(full_path.read_text().rstrip() or "(empty)")
|
||||
|
||||
|
||||
def _run_config(*, sandbox: BaseSandboxSession, workflow_name: str) -> RunConfig:
|
||||
return RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name=workflow_name,
|
||||
tracing_disabled=True,
|
||||
)
|
||||
|
||||
|
||||
async def main(*, model: str) -> None:
|
||||
manifest = _build_manifest()
|
||||
agent = _build_agent(model=model, manifest=manifest)
|
||||
client = UnixLocalSandboxClient()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_dir:
|
||||
# Use a local snapshot so the second run resumes the same workspace in a new sandbox
|
||||
# session. That makes the second prompt rely on memory instead of in-process agent state.
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
|
||||
)
|
||||
workspace_root = Path(sandbox.state.manifest.root)
|
||||
|
||||
try:
|
||||
async with sandbox:
|
||||
# Run 1 fixes the bug and generates memory artifacts when the session closes.
|
||||
first = await Runner.run(
|
||||
agent,
|
||||
FIRST_PROMPT,
|
||||
run_config=_run_config(
|
||||
sandbox=sandbox,
|
||||
workflow_name="Sandbox memory example: initial fix",
|
||||
),
|
||||
max_turns=20,
|
||||
)
|
||||
print("\n[first run]")
|
||||
print(first.final_output)
|
||||
|
||||
resumed_sandbox = await client.resume(sandbox.state)
|
||||
async with resumed_sandbox:
|
||||
# Run 2 starts from the resumed snapshot and reads the memory generated by run 1
|
||||
# before answering the follow-up prompt.
|
||||
second = await Runner.run(
|
||||
agent,
|
||||
SECOND_PROMPT,
|
||||
run_config=_run_config(
|
||||
sandbox=resumed_sandbox,
|
||||
workflow_name="Sandbox memory example: follow-up",
|
||||
),
|
||||
max_turns=20,
|
||||
)
|
||||
print("\n[second run]")
|
||||
print(second.final_output)
|
||||
|
||||
_print_memory_tree(workspace_root)
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run one sandbox agent twice across a snapshot resume with shared memory."
|
||||
)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(model=args.model))
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from agents import Runner, SQLiteSession
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, MemoryLayoutConfig, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.capabilities import Filesystem, Memory, Shell
|
||||
from agents.sandbox.entries import Dir, File
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
DEFAULT_MODEL = "gpt-5.6-sol"
|
||||
GTM_SESSION_ID = "gtm-q2-pipeline-review"
|
||||
ENGINEERING_SESSION_ID = "eng-invoice-test-fix"
|
||||
|
||||
GTM_TURN_1 = (
|
||||
"Analyze data/leads.csv. Find one promising GTM segment, explain why, and say what "
|
||||
"follow-up data you need."
|
||||
)
|
||||
GTM_TURN_2 = (
|
||||
"Using your previous GTM analysis, write a short outreach hypothesis and save it to "
|
||||
"gtm_hypothesis.md."
|
||||
)
|
||||
ENGINEERING_TURN = (
|
||||
"Fix the invoice total bug in src/acme_metrics/report.py, then run the test suite."
|
||||
)
|
||||
|
||||
|
||||
def _build_manifest() -> Manifest:
|
||||
return Manifest(
|
||||
entries={
|
||||
"data": Dir(
|
||||
children={
|
||||
"leads.csv": File(
|
||||
content=(
|
||||
b"account,segment,seats,trial_events,monthly_spend\n"
|
||||
b"Northstar Health,healthcare,240,98,18000\n"
|
||||
b"Beacon Retail,retail,75,18,4200\n"
|
||||
b"Apex Fintech,financial-services,180,76,13500\n"
|
||||
b"Summit Labs,healthcare,52,22,3900\n"
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
"pyproject.toml": File(
|
||||
content=(
|
||||
b"[project]\n"
|
||||
b'name = "acme-metrics"\n'
|
||||
b'version = "0.1.0"\n'
|
||||
b'requires-python = ">=3.10"\n'
|
||||
b"\n"
|
||||
b"[tool.pytest.ini_options]\n"
|
||||
b'pythonpath = ["src"]\n'
|
||||
)
|
||||
),
|
||||
"src": Dir(
|
||||
children={
|
||||
"acme_metrics": Dir(
|
||||
children={
|
||||
"__init__.py": File(
|
||||
content=b"from .report import format_invoice_total\n"
|
||||
),
|
||||
"report.py": File(
|
||||
content=(
|
||||
b"from __future__ import annotations\n\n"
|
||||
b"def format_invoice_total(subtotal: float, tax_rate: float) -> str:\n"
|
||||
b" total = subtotal + tax_rate\n"
|
||||
b' return f"${total:.2f}"\n'
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
"tests": Dir(
|
||||
children={
|
||||
"test_report.py": File(
|
||||
content=(
|
||||
b"from acme_metrics import format_invoice_total\n\n\n"
|
||||
b"def test_format_invoice_total_applies_tax_rate() -> None:\n"
|
||||
b' assert format_invoice_total(100.0, 0.075) == "$107.50"\n'
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_gtm_agent(*, model: str, manifest: Manifest) -> SandboxAgent:
|
||||
return SandboxAgent(
|
||||
name="GTM analyst",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You are a GTM analyst. Inspect the workspace data before answering. Keep analysis "
|
||||
"specific and cite file paths you used."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[
|
||||
# Same layout + same SDK session across turns means one memory conversation.
|
||||
Memory(
|
||||
layout=MemoryLayoutConfig(
|
||||
memories_dir="memories/gtm",
|
||||
sessions_dir="sessions/gtm",
|
||||
)
|
||||
),
|
||||
Filesystem(),
|
||||
Shell(),
|
||||
Filesystem(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _build_engineering_agent(*, model: str, manifest: Manifest) -> SandboxAgent:
|
||||
return SandboxAgent(
|
||||
name="Engineering fixer",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You are an engineer. Inspect files before editing, make minimal changes, and verify "
|
||||
"with tests. Use a non-login POSIX shell for commands. Make one focused pytest attempt; "
|
||||
"if the local sandbox blocks Python or toolchain access, report that validation was "
|
||||
"blocked and finish instead of retrying repeatedly."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[
|
||||
# Different layout keeps engineering memory separate even in the same sandbox workspace.
|
||||
Memory(
|
||||
layout=MemoryLayoutConfig(
|
||||
memories_dir="memories/engineering",
|
||||
sessions_dir="sessions/engineering",
|
||||
)
|
||||
),
|
||||
Shell(),
|
||||
Filesystem(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _print_tree(
|
||||
root: Path, label: str, relative_path: str, *, print_file_contents: bool = False
|
||||
) -> None:
|
||||
print(f"\n[{label}]")
|
||||
base = root / relative_path
|
||||
if not base.exists():
|
||||
print(f"{relative_path} (missing)")
|
||||
return
|
||||
for path in sorted(base.rglob("*")):
|
||||
if path.is_file():
|
||||
print(path.relative_to(root))
|
||||
if print_file_contents:
|
||||
contents = path.read_text().rstrip()
|
||||
if not contents:
|
||||
print(" (empty)")
|
||||
else:
|
||||
for line in contents.splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
async def main(*, model: str) -> None:
|
||||
manifest = _build_manifest()
|
||||
gtm_agent = _build_gtm_agent(model=model, manifest=manifest)
|
||||
engineering_agent = _build_engineering_agent(model=model, manifest=manifest)
|
||||
client = UnixLocalSandboxClient()
|
||||
sandbox = await client.create(manifest=manifest)
|
||||
workspace_root = Path(sandbox.state.manifest.root)
|
||||
|
||||
try:
|
||||
async with sandbox:
|
||||
gtm_conversation_session = SQLiteSession(GTM_SESSION_ID)
|
||||
gtm_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name="GTM memory layout example",
|
||||
)
|
||||
gtm_first = await Runner.run(
|
||||
gtm_agent,
|
||||
GTM_TURN_1,
|
||||
session=gtm_conversation_session,
|
||||
run_config=gtm_config,
|
||||
)
|
||||
print("\n[gtm turn 1]")
|
||||
print(gtm_first.final_output)
|
||||
|
||||
# Reuse the SDK session so the model sees prior turns and memory extracts them together.
|
||||
gtm_second = await Runner.run(
|
||||
gtm_agent,
|
||||
GTM_TURN_2,
|
||||
session=gtm_conversation_session,
|
||||
run_config=gtm_config,
|
||||
)
|
||||
print("\n[gtm turn 2]")
|
||||
print(gtm_second.final_output)
|
||||
|
||||
engineering_conversation_session = SQLiteSession(ENGINEERING_SESSION_ID)
|
||||
engineering_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name="Engineering memory layout example",
|
||||
)
|
||||
engineering = await Runner.run(
|
||||
engineering_agent,
|
||||
ENGINEERING_TURN,
|
||||
session=engineering_conversation_session,
|
||||
run_config=engineering_config,
|
||||
max_turns=20,
|
||||
)
|
||||
print("\n[engineering]")
|
||||
print(engineering.final_output)
|
||||
|
||||
_print_tree(workspace_root, "gtm memory", "memories/gtm")
|
||||
_print_tree(workspace_root, "engineering memory", "memories/engineering")
|
||||
_print_tree(workspace_root, "gtm sessions", "sessions/gtm", print_file_contents=True)
|
||||
_print_tree(
|
||||
workspace_root,
|
||||
"engineering sessions",
|
||||
"sessions/engineering",
|
||||
print_file_contents=True,
|
||||
)
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run two sandbox agents with separate memory layouts in one workspace."
|
||||
)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(main(model=args.model))
|
||||
@@ -0,0 +1,329 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from agents import Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import (
|
||||
Manifest,
|
||||
MemoryGenerateConfig,
|
||||
MemoryLayoutConfig,
|
||||
SandboxAgent,
|
||||
SandboxRunConfig,
|
||||
)
|
||||
from agents.sandbox.capabilities import Filesystem, Memory, Shell
|
||||
from agents.sandbox.entries import File, InContainerMountStrategy, RcloneMountPattern, S3Mount
|
||||
from agents.sandbox.sandboxes.docker import (
|
||||
DockerSandboxClient,
|
||||
DockerSandboxClientOptions,
|
||||
)
|
||||
from agents.sandbox.session import SandboxSession
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from examples.sandbox.basic import _import_docker_from_env
|
||||
from examples.sandbox.docker.mounts.mount_smoke import IMAGE as MOUNT_IMAGE, ensure_mount_image
|
||||
|
||||
DEFAULT_MODEL = "gpt-5.6-sol"
|
||||
DEFAULT_MOUNT_DIR = "persistent"
|
||||
FIRST_PROMPT = "Inspect workspace and fix invoice total bug in src/acme_metrics/report.py."
|
||||
SECOND_PROMPT = (
|
||||
"Add a regression test for the previous bug you fixed. Put it in "
|
||||
"tests/test_invoice_regression.py."
|
||||
)
|
||||
MEMORY_EXTRA_PROMPT = (
|
||||
"This is an S3-backed memory demo. If a run fixes a concrete code bug, remember the "
|
||||
"specific file path, test expectation, root cause, and patch so a future fresh sandbox can "
|
||||
"reuse the fix instead of rediscovering it."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class S3MemoryExampleConfig:
|
||||
bucket: str
|
||||
access_key_id: str | None
|
||||
secret_access_key: str | None
|
||||
session_token: str | None
|
||||
region: str | None
|
||||
endpoint_url: str | None
|
||||
prefix: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, *, prefix: str | None = None) -> S3MemoryExampleConfig:
|
||||
bucket = os.getenv("S3_BUCKET") or os.getenv("S3_MOUNT_BUCKET")
|
||||
if not bucket:
|
||||
raise SystemExit(
|
||||
"Missing S3 bucket name. Set S3_BUCKET or S3_MOUNT_BUCKET. "
|
||||
"This example works well with: source ~/.s3.env"
|
||||
)
|
||||
resolved_prefix = (
|
||||
prefix
|
||||
or os.getenv("S3_MOUNT_PREFIX", f"sandbox-memory-example/{uuid.uuid4().hex}")
|
||||
or f"sandbox-memory-example/{uuid.uuid4().hex}"
|
||||
)
|
||||
return cls(
|
||||
bucket=bucket,
|
||||
access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
session_token=os.getenv("AWS_SESSION_TOKEN"),
|
||||
region=os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION"),
|
||||
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
|
||||
prefix=resolved_prefix.strip("/"),
|
||||
)
|
||||
|
||||
|
||||
def _persistent_layout(*, mount_dir: str = DEFAULT_MOUNT_DIR) -> MemoryLayoutConfig:
|
||||
return MemoryLayoutConfig(
|
||||
memories_dir=f"{mount_dir}/memories",
|
||||
sessions_dir=f"{mount_dir}/sessions",
|
||||
)
|
||||
|
||||
|
||||
def _artifact_paths(*, mount_dir: str = DEFAULT_MOUNT_DIR) -> tuple[Path, ...]:
|
||||
layout = _persistent_layout(mount_dir=mount_dir)
|
||||
return (
|
||||
Path(layout.sessions_dir),
|
||||
Path(layout.memories_dir) / "MEMORY.md",
|
||||
Path(layout.memories_dir) / "memory_summary.md",
|
||||
Path(layout.memories_dir) / "raw_memories.md",
|
||||
Path(layout.memories_dir) / "raw_memories",
|
||||
Path(layout.memories_dir) / "rollout_summaries",
|
||||
)
|
||||
|
||||
|
||||
def _build_manifest(
|
||||
*, config: S3MemoryExampleConfig, mount_dir: str = DEFAULT_MOUNT_DIR
|
||||
) -> Manifest:
|
||||
return Manifest(
|
||||
entries={
|
||||
"README.md": File(
|
||||
content=(
|
||||
b"# Acme Metrics\n\n"
|
||||
b"Small demo package for validating invoice total formatting.\n"
|
||||
)
|
||||
),
|
||||
"pyproject.toml": File(
|
||||
content=(
|
||||
b"[project]\n"
|
||||
b'name = "acme-metrics"\n'
|
||||
b'version = "0.1.0"\n'
|
||||
b'requires-python = ">=3.10"\n'
|
||||
b"\n"
|
||||
b"[tool.pytest.ini_options]\n"
|
||||
b'pythonpath = ["src"]\n'
|
||||
)
|
||||
),
|
||||
"src/acme_metrics/__init__.py": File(
|
||||
content=b"from .report import format_invoice_total\n"
|
||||
),
|
||||
"src/acme_metrics/report.py": File(
|
||||
content=(
|
||||
b"from __future__ import annotations\n\n"
|
||||
b"def format_invoice_total(subtotal: float, tax_rate: float) -> str:\n"
|
||||
b" total = subtotal + tax_rate\n"
|
||||
b' return f"${total:.2f}"\n'
|
||||
)
|
||||
),
|
||||
"tests/test_report.py": File(
|
||||
content=(
|
||||
b"from acme_metrics import format_invoice_total\n\n\n"
|
||||
b"def test_format_invoice_total_applies_tax_rate() -> None:\n"
|
||||
b' assert format_invoice_total(100.0, 0.075) == "$107.50"\n'
|
||||
)
|
||||
),
|
||||
mount_dir: S3Mount(
|
||||
bucket=config.bucket,
|
||||
access_key_id=config.access_key_id,
|
||||
secret_access_key=config.secret_access_key,
|
||||
session_token=config.session_token,
|
||||
prefix=config.prefix,
|
||||
region=config.region,
|
||||
endpoint_url=config.endpoint_url,
|
||||
mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()),
|
||||
read_only=False,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_agent(
|
||||
*, model: str, manifest: Manifest, mount_dir: str = DEFAULT_MOUNT_DIR
|
||||
) -> SandboxAgent:
|
||||
return SandboxAgent(
|
||||
name="Sandbox Memory S3 Demo",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Answer questions about the sandbox workspace. Inspect files before answering, make "
|
||||
"minimal edits, and keep the response concise. "
|
||||
"Use the shell tool to inspect and validate the workspace. Use apply_patch for text "
|
||||
"edits when it is the clearest option. Do not invent files you did not read."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[
|
||||
Memory(
|
||||
layout=_persistent_layout(mount_dir=mount_dir),
|
||||
generate=MemoryGenerateConfig(extra_prompt=MEMORY_EXTRA_PROMPT),
|
||||
),
|
||||
Filesystem(),
|
||||
Shell(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _run_config(*, sandbox: SandboxSession, workflow_name: str) -> RunConfig:
|
||||
return RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name=workflow_name,
|
||||
tracing_disabled=True,
|
||||
)
|
||||
|
||||
|
||||
async def _read_text(session: SandboxSession, path: str) -> str:
|
||||
handle = await session.read(Path(path))
|
||||
try:
|
||||
payload = handle.read()
|
||||
finally:
|
||||
handle.close()
|
||||
if isinstance(payload, bytes):
|
||||
return payload.decode("utf-8")
|
||||
return str(payload)
|
||||
|
||||
|
||||
async def _path_exists(session: SandboxSession, path: Path) -> bool:
|
||||
result = await session.exec("test", "-e", str(path), shell=False)
|
||||
return result.ok()
|
||||
|
||||
|
||||
async def _path_is_dir(session: SandboxSession, path: Path) -> bool:
|
||||
result = await session.exec("test", "-d", str(path), shell=False)
|
||||
return result.ok()
|
||||
|
||||
|
||||
async def _assert_fixed(session: SandboxSession) -> None:
|
||||
report_py = await _read_text(session, "src/acme_metrics/report.py")
|
||||
if "subtotal * (1 + tax_rate)" not in report_py:
|
||||
raise RuntimeError("Sandbox did not apply expected invoice total fix.")
|
||||
|
||||
|
||||
async def _assert_memory_summary_generated(session: SandboxSession) -> None:
|
||||
memory_summary = await _read_text(session, f"{DEFAULT_MOUNT_DIR}/memories/memory_summary.md")
|
||||
if not memory_summary.strip():
|
||||
raise RuntimeError(
|
||||
"First sandbox session did not generate a memory summary in S3-backed storage."
|
||||
)
|
||||
|
||||
|
||||
async def _assert_regression_test_added(session: SandboxSession) -> None:
|
||||
test_path = Path("tests/test_invoice_regression.py")
|
||||
if not await _path_exists(session, test_path):
|
||||
raise RuntimeError("Sandbox did not add the expected regression test file.")
|
||||
|
||||
regression_test = await _read_text(session, str(test_path))
|
||||
if "format_invoice_total" not in regression_test:
|
||||
raise RuntimeError("Regression test does not exercise format_invoice_total.")
|
||||
|
||||
|
||||
async def _print_tree(session: SandboxSession, *, mount_dir: str = DEFAULT_MOUNT_DIR) -> None:
|
||||
print("\nS3-backed memory artifacts:")
|
||||
for relative_path in _artifact_paths(mount_dir=mount_dir):
|
||||
if not await _path_exists(session, relative_path):
|
||||
print(f"- {relative_path} (missing)")
|
||||
continue
|
||||
if await _path_is_dir(session, relative_path):
|
||||
print(f"- {relative_path}/")
|
||||
children = await session.ls(relative_path)
|
||||
for child in sorted(children, key=lambda entry: entry.path):
|
||||
child_name = Path(child.path).name
|
||||
if child_name in {".", ".."}:
|
||||
continue
|
||||
print(f" - {relative_path / child_name}")
|
||||
continue
|
||||
print(f"- {relative_path}")
|
||||
print((await _read_text(session, str(relative_path))).rstrip() or "(empty)")
|
||||
|
||||
|
||||
async def _create_session(*, manifest: Manifest) -> tuple[DockerSandboxClient, SandboxSession]:
|
||||
docker_from_env = _import_docker_from_env()
|
||||
docker_client = docker_from_env()
|
||||
sandbox_client = DockerSandboxClient(docker_client)
|
||||
sandbox = await sandbox_client.create(
|
||||
manifest=manifest,
|
||||
options=DockerSandboxClientOptions(image=MOUNT_IMAGE),
|
||||
)
|
||||
return sandbox_client, sandbox
|
||||
|
||||
|
||||
async def _print_persisted_tree(*, manifest: Manifest) -> None:
|
||||
inspect_client, inspect_sandbox = await _create_session(manifest=manifest)
|
||||
try:
|
||||
async with inspect_sandbox:
|
||||
await _print_tree(inspect_sandbox)
|
||||
finally:
|
||||
await inspect_client.delete(inspect_sandbox)
|
||||
|
||||
|
||||
async def main(*, model: str, prefix: str | None) -> None:
|
||||
ensure_mount_image()
|
||||
config = S3MemoryExampleConfig.from_env(prefix=prefix)
|
||||
manifest = _build_manifest(config=config)
|
||||
agent = _build_agent(model=model, manifest=manifest)
|
||||
|
||||
first_client, first_sandbox = await _create_session(manifest=manifest)
|
||||
try:
|
||||
async with first_sandbox:
|
||||
first = await Runner.run(
|
||||
agent,
|
||||
FIRST_PROMPT,
|
||||
run_config=_run_config(
|
||||
sandbox=first_sandbox,
|
||||
workflow_name="Sandbox memory S3 example: first sandbox",
|
||||
),
|
||||
)
|
||||
print("\n[first sandbox]")
|
||||
print(first.final_output)
|
||||
await _assert_fixed(first_sandbox)
|
||||
finally:
|
||||
await first_client.delete(first_sandbox)
|
||||
|
||||
second_client, second_sandbox = await _create_session(manifest=manifest)
|
||||
try:
|
||||
async with second_sandbox:
|
||||
await _assert_memory_summary_generated(second_sandbox)
|
||||
|
||||
second = await Runner.run(
|
||||
agent,
|
||||
SECOND_PROMPT,
|
||||
run_config=_run_config(
|
||||
sandbox=second_sandbox,
|
||||
workflow_name="Sandbox memory S3 example: second sandbox",
|
||||
),
|
||||
)
|
||||
print("\n[second sandbox]")
|
||||
print(second.final_output)
|
||||
await _assert_regression_test_added(second_sandbox)
|
||||
finally:
|
||||
await second_client.delete(second_sandbox)
|
||||
|
||||
await _print_persisted_tree(manifest=manifest)
|
||||
print(f"\nS3 prefix: {config.prefix}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run sandbox memory across two fresh Docker sandboxes with S3-backed storage."
|
||||
)
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
|
||||
parser.add_argument(
|
||||
"--prefix",
|
||||
default=None,
|
||||
help="Optional S3 prefix for mounted memory artifacts. Defaults to a unique prefix.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(model=args.model, prefix=args.prefix))
|
||||
@@ -0,0 +1 @@
|
||||
# Shared support code for sandbox examples.
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from agents.sandbox import Manifest
|
||||
from agents.sandbox.entries import File
|
||||
|
||||
|
||||
def text_manifest(files: Mapping[str, str]) -> Manifest:
|
||||
"""Build a manifest from in-memory UTF-8 text files."""
|
||||
|
||||
return Manifest(
|
||||
entries={path: File(content=contents.encode("utf-8")) for path, contents in files.items()}
|
||||
)
|
||||
|
||||
|
||||
def tool_call_name(raw_item: object) -> str:
|
||||
"""Return a readable name for a raw tool call item."""
|
||||
|
||||
if isinstance(raw_item, dict):
|
||||
name = raw_item.get("name")
|
||||
item_type = raw_item.get("type")
|
||||
else:
|
||||
name = getattr(raw_item, "name", None)
|
||||
item_type = getattr(raw_item, "type", None)
|
||||
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
if item_type == "shell_call":
|
||||
return "shell"
|
||||
if isinstance(item_type, str):
|
||||
return item_type
|
||||
return ""
|
||||
@@ -0,0 +1,25 @@
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("Reference Policy Server")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_policy_reference(topic: str) -> str:
|
||||
"""Return short internal policy guidance for a supported topic."""
|
||||
normalized = topic.strip().lower()
|
||||
if "discount" in normalized:
|
||||
return (
|
||||
"Discount policy: discounts from 11 to 15 percent require regional sales director "
|
||||
"approval. Discounts above 15 percent require both finance and the regional sales "
|
||||
"director."
|
||||
)
|
||||
if "security" in normalized or "review" in normalized:
|
||||
return (
|
||||
"Security review policy: any new data export workflow must finish security review "
|
||||
"before kickoff or production access."
|
||||
)
|
||||
return "No policy reference is available for that topic in this demo."
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from agents import ApplyPatchTool, apply_diff
|
||||
from agents.editor import ApplyPatchOperation, ApplyPatchResult
|
||||
from agents.sandbox import Capability, Manifest
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.tool import Tool
|
||||
|
||||
|
||||
def _read_text(handle: io.IOBase) -> str:
|
||||
payload = handle.read()
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
if isinstance(payload, bytes | bytearray):
|
||||
return bytes(payload).decode("utf-8", errors="replace")
|
||||
return str(payload)
|
||||
|
||||
|
||||
class _SandboxWorkspaceEditor:
|
||||
def __init__(self, session: BaseSandboxSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def create_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
||||
target = self._resolve_path(operation.path)
|
||||
content = apply_diff("", operation.diff or "", mode="create")
|
||||
await self._session.mkdir(target.parent, parents=True)
|
||||
await self._session.write(target, io.BytesIO(content.encode("utf-8")))
|
||||
return ApplyPatchResult(output=f"Created {self._display_path(target)}")
|
||||
|
||||
async def update_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
||||
target = self._resolve_path(operation.path)
|
||||
handle = await self._session.read(target)
|
||||
try:
|
||||
original = _read_text(handle)
|
||||
finally:
|
||||
handle.close()
|
||||
updated = apply_diff(original, operation.diff or "")
|
||||
await self._session.write(target, io.BytesIO(updated.encode("utf-8")))
|
||||
return ApplyPatchResult(output=f"Updated {self._display_path(target)}")
|
||||
|
||||
async def delete_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
|
||||
target = self._resolve_path(operation.path)
|
||||
await self._session.rm(target)
|
||||
return ApplyPatchResult(output=f"Deleted {self._display_path(target)}")
|
||||
|
||||
def _resolve_path(self, raw_path: str) -> Path:
|
||||
return self._session.normalize_path(raw_path)
|
||||
|
||||
def _display_path(self, path: Path) -> str:
|
||||
root = Path(self._session.state.manifest.root)
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
class WorkspaceApplyPatchCapability(Capability):
|
||||
"""Expose the hosted apply_patch tool against the active sandbox workspace."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(type="workspace_apply_patch")
|
||||
self._session: BaseSandboxSession | None = None
|
||||
|
||||
def bind(self, session: BaseSandboxSession) -> None:
|
||||
self._session = session
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
if self._session is None:
|
||||
return []
|
||||
return [ApplyPatchTool(editor=_SandboxWorkspaceEditor(self._session))]
|
||||
|
||||
async def instructions(self, manifest: Manifest) -> str | None:
|
||||
_ = manifest
|
||||
return (
|
||||
"Use the `apply_patch` tool for workspace text edits when you need to create or "
|
||||
"update files inside the sandbox. Prefer saving final outputs in the requested "
|
||||
"workspace directories instead of describing edits without writing them."
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agents.sandbox import Capability, Manifest
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.tool import (
|
||||
ShellCallOutcome,
|
||||
ShellCommandOutput,
|
||||
ShellCommandRequest,
|
||||
ShellResult,
|
||||
ShellTool,
|
||||
Tool,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceShellCapability(Capability):
|
||||
"""Expose one shell tool for inspecting the active sandbox workspace."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(type="workspace_shell")
|
||||
self._session: BaseSandboxSession | None = None
|
||||
|
||||
def bind(self, session: BaseSandboxSession) -> None:
|
||||
self._session = session
|
||||
|
||||
def tools(self) -> list[Tool]:
|
||||
return [ShellTool(executor=self._execute_shell)]
|
||||
|
||||
async def instructions(self, manifest: Manifest) -> str | None:
|
||||
_ = manifest
|
||||
return (
|
||||
"Use the `shell` tool to inspect the sandbox workspace before answering. "
|
||||
"The workspace root is the current working directory, so prefer relative paths "
|
||||
"with commands like `pwd`, `find .`, and `cat`. Only cite files you actually read."
|
||||
)
|
||||
|
||||
async def _execute_shell(self, request: ShellCommandRequest) -> ShellResult:
|
||||
if self._session is None:
|
||||
raise RuntimeError("Workspace shell is not bound to a sandbox session.")
|
||||
|
||||
timeout_s = (
|
||||
request.data.action.timeout_ms / 1000
|
||||
if request.data.action.timeout_ms is not None
|
||||
else None
|
||||
)
|
||||
outputs: list[ShellCommandOutput] = []
|
||||
for command in request.data.action.commands:
|
||||
result = await self._session.exec(command, timeout=timeout_s, shell=True)
|
||||
outputs.append(
|
||||
ShellCommandOutput(
|
||||
command=command,
|
||||
stdout=result.stdout.decode("utf-8", errors="replace"),
|
||||
stderr=result.stderr.decode("utf-8", errors="replace"),
|
||||
outcome=ShellCallOutcome(type="exit", exit_code=result.exit_code),
|
||||
)
|
||||
)
|
||||
return ShellResult(output=outputs)
|
||||
@@ -0,0 +1,475 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from openai.types.responses import ResponseFunctionCallArgumentsDeltaEvent, ResponseTextDeltaEvent
|
||||
from openai.types.responses.response_prompt_param import ResponsePromptParam
|
||||
|
||||
from agents import (
|
||||
AgentOutputSchemaBase,
|
||||
AgentUpdatedStreamEvent,
|
||||
ApplyPatchOperation,
|
||||
Handoff,
|
||||
ItemHelpers,
|
||||
Model,
|
||||
ModelResponse,
|
||||
ModelSettings,
|
||||
ModelTracing,
|
||||
OpenAIProvider,
|
||||
RawResponsesStreamEvent,
|
||||
RunContextWrapper,
|
||||
RunItemStreamEvent,
|
||||
Runner,
|
||||
RunResultStreaming,
|
||||
Tool,
|
||||
ToolOutputImage,
|
||||
)
|
||||
from agents.items import (
|
||||
ToolCallItem,
|
||||
ToolCallOutputItem,
|
||||
TResponseInputItem,
|
||||
TResponseStreamEvent,
|
||||
)
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import LocalFile, Manifest, SandboxAgent, SandboxPathGrant, SandboxRunConfig
|
||||
from agents.sandbox.capabilities import (
|
||||
Filesystem,
|
||||
FilesystemToolSet,
|
||||
LocalDirLazySkillSource,
|
||||
Skills,
|
||||
)
|
||||
from agents.sandbox.capabilities.capabilities import Capabilities
|
||||
from agents.sandbox.entries import File, LocalDir
|
||||
from agents.sandbox.errors import WorkspaceReadNotFoundError
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
|
||||
DEFAULT_MODEL = "gpt-5.5"
|
||||
COMPACTION_THRESHOLD = 1_000
|
||||
VERIFICATION_FILE = Path("verification/capabilities.txt")
|
||||
DELETE_FILE = Path("verification/delete-me.txt")
|
||||
|
||||
|
||||
class RecordingModel(Model):
|
||||
def __init__(self, model_name: str) -> None:
|
||||
self._model = OpenAIProvider().get_model(model_name)
|
||||
self.first_input: str | list[TResponseInputItem] | None = None
|
||||
self.first_model_settings: ModelSettings | None = None
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem],
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> ModelResponse:
|
||||
if self.first_input is None:
|
||||
self.first_input = input
|
||||
self.first_model_settings = model_settings
|
||||
return await self._model.get_response(
|
||||
system_instructions,
|
||||
input,
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
def stream_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem],
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
if self.first_input is None:
|
||||
self.first_input = input
|
||||
self.first_model_settings = model_settings
|
||||
return self._model.stream_response(
|
||||
system_instructions,
|
||||
input,
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._model.close()
|
||||
|
||||
|
||||
def _build_manifest(skills_root: Path) -> Manifest:
|
||||
return Manifest(
|
||||
extra_path_grants=(SandboxPathGrant(path=str(skills_root)),),
|
||||
entries={
|
||||
"README.md": File(
|
||||
content=(
|
||||
b"# Capability Smoke Workspace\n\n"
|
||||
b"This workspace is used to verify sandbox capabilities end to end.\n"
|
||||
b"Project code name: atlas.\n"
|
||||
)
|
||||
),
|
||||
"notes/input.txt": File(content=b"source=filesystem\n"),
|
||||
"examples/image.png": LocalFile(
|
||||
src=Path(__file__).parent.parent.parent / "docs/assets/images/graph.png"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _write_local_skill(skills_root: Path) -> None:
|
||||
skill_dir = skills_root / "capability-proof"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"---",
|
||||
"name: capability-proof",
|
||||
"description: Verifies the sandbox skills capability in the smoke example.",
|
||||
"---",
|
||||
"",
|
||||
"# Capability Proof",
|
||||
"",
|
||||
"When loaded, write a verification file containing these exact lines:",
|
||||
"- skill_loaded=true",
|
||||
"- codename=atlas",
|
||||
"- note_source=filesystem",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _build_agent(model: RecordingModel, skills_root: Path) -> SandboxAgent:
|
||||
capabilities = Capabilities.default() + [
|
||||
Skills(
|
||||
lazy_from=LocalDirLazySkillSource(
|
||||
# This is a host path read by the SDK process.
|
||||
# Requested skills are copied into `skills_path` in the sandbox.
|
||||
source=LocalDir(src=skills_root),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
def apply_patch_needs_approval(
|
||||
ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, call_id: str
|
||||
):
|
||||
return False
|
||||
|
||||
def _configure_filesystem(toolset: FilesystemToolSet):
|
||||
toolset.apply_patch.needs_approval = apply_patch_needs_approval
|
||||
|
||||
for capability in capabilities:
|
||||
if isinstance(capability, Filesystem):
|
||||
capability.configure_tools = _configure_filesystem
|
||||
|
||||
return SandboxAgent(
|
||||
name="Sandbox Capabilities Smoke",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Run the sandbox capability smoke test end to end, use the available tools "
|
||||
"deliberately, and then give a one-line final summary. "
|
||||
"Follow this sequence:\n"
|
||||
"1. Inspect the workspace root at `.`.\n"
|
||||
"2. Read `README.md`.\n"
|
||||
"3. Use `view_image` on `examples/image.png` and confirm it shows a routing diagram "
|
||||
"centered on `Triage Agent`.\n"
|
||||
"4. Use the `capability-proof` skill.\n"
|
||||
f"5. Create `{VERIFICATION_FILE.as_posix()}` with exactly these two lines:\n"
|
||||
" skill_loaded=true\n"
|
||||
" codename=atlas\n"
|
||||
"6. Use the apply_patch tool to update that file so it has exactly these four lines:\n"
|
||||
" skill_loaded=true\n"
|
||||
" codename=atlas\n"
|
||||
" note_source=filesystem\n"
|
||||
" image_verified=true\n"
|
||||
f"7. Create `{DELETE_FILE.as_posix()}`, then delete it.\n"
|
||||
f"8. Print `{VERIFICATION_FILE.as_posix()}` from the shell.\n"
|
||||
"When referring to the workspace root in any path argument, use `.` exactly. Do not "
|
||||
"use an empty string for a path.\n"
|
||||
"Keep the final answer to one line: `capability smoke complete`."
|
||||
),
|
||||
default_manifest=_build_manifest(skills_root),
|
||||
capabilities=capabilities,
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
|
||||
def _initial_input() -> list[TResponseInputItem]:
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Run the sandbox capability smoke test now. Use the listed tools and then answer "
|
||||
"with `capability smoke complete`."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _tool_call_name(item: ToolCallItem) -> str:
|
||||
raw_item = item.raw_item
|
||||
if isinstance(raw_item, dict):
|
||||
if raw_item.get("type") == "apply_patch_call":
|
||||
return "apply_patch"
|
||||
return cast(str, raw_item.get("name") or raw_item.get("type") or "")
|
||||
return cast(str, getattr(raw_item, "name", None) or getattr(raw_item, "type", None) or "")
|
||||
|
||||
|
||||
async def _read_workspace_text(session: BaseSandboxSession, path: Path) -> str:
|
||||
handle = await session.read(path)
|
||||
try:
|
||||
payload = handle.read()
|
||||
finally:
|
||||
handle.close()
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
return bytes(payload).decode("utf-8")
|
||||
|
||||
|
||||
def _format_tool_call_arguments(item: ToolCallItem) -> str | None:
|
||||
raw_item = item.raw_item
|
||||
if isinstance(raw_item, dict):
|
||||
arguments = raw_item.get("arguments")
|
||||
else:
|
||||
arguments = getattr(raw_item, "arguments", None)
|
||||
if not isinstance(arguments, str) or arguments == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
return arguments
|
||||
return json.dumps(parsed, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def _format_tool_output(output: object) -> str:
|
||||
text = str(output)
|
||||
if len(text) <= 240:
|
||||
return text
|
||||
return f"{text[:240]}..."
|
||||
|
||||
|
||||
async def _print_stream_details(result: RunResultStreaming) -> None:
|
||||
print("=== Stream starting ===")
|
||||
print("Streaming raw text deltas, tool activity, and semantic run events as they arrive.\n")
|
||||
|
||||
active_tool_call: str | None = None
|
||||
text_stream_open = False
|
||||
|
||||
async for event in result.stream_events():
|
||||
if isinstance(event, AgentUpdatedStreamEvent):
|
||||
if text_stream_open:
|
||||
print()
|
||||
text_stream_open = False
|
||||
print(f"[agent] switched to: {event.new_agent.name}")
|
||||
continue
|
||||
|
||||
if isinstance(event, RawResponsesStreamEvent):
|
||||
data = event.data
|
||||
if isinstance(data, ResponseTextDeltaEvent):
|
||||
if not text_stream_open:
|
||||
print("[model:text] ", end="", flush=True)
|
||||
text_stream_open = True
|
||||
print(data.delta, end="", flush=True)
|
||||
continue
|
||||
if isinstance(data, ResponseFunctionCallArgumentsDeltaEvent):
|
||||
if text_stream_open:
|
||||
print()
|
||||
text_stream_open = False
|
||||
if active_tool_call is None:
|
||||
active_tool_call = "tool"
|
||||
print("[model:tool_args] ", end="", flush=True)
|
||||
print(data.delta, end="", flush=True)
|
||||
continue
|
||||
|
||||
event_type = getattr(data, "type", None)
|
||||
if event_type == "response.output_item.done" and active_tool_call is not None:
|
||||
print()
|
||||
print(f"[model:tool_args] completed for {active_tool_call}")
|
||||
active_tool_call = None
|
||||
continue
|
||||
|
||||
if text_stream_open:
|
||||
print()
|
||||
text_stream_open = False
|
||||
if active_tool_call is not None:
|
||||
print()
|
||||
active_tool_call = None
|
||||
|
||||
if not isinstance(event, RunItemStreamEvent):
|
||||
continue
|
||||
|
||||
if event.item.type == "tool_call_item":
|
||||
tool_name = _tool_call_name(event.item)
|
||||
active_tool_call = tool_name
|
||||
print(f"[tool:call] {tool_name}")
|
||||
arguments = _format_tool_call_arguments(event.item)
|
||||
if arguments:
|
||||
print(arguments)
|
||||
elif event.item.type == "tool_call_output_item":
|
||||
print(f"[tool:output] {_format_tool_output(event.item.output)}")
|
||||
elif event.item.type == "message_output_item":
|
||||
message_text = ItemHelpers.text_message_output(event.item)
|
||||
print(f"[message:complete] {len(message_text)} characters")
|
||||
elif event.item.type == "reasoning_item":
|
||||
print("[reasoning] model emitted a reasoning item")
|
||||
else:
|
||||
print(f"[event:{event.name}] item_type={event.item.type}")
|
||||
|
||||
if text_stream_open:
|
||||
print()
|
||||
print("\n=== Stream complete ===")
|
||||
|
||||
|
||||
async def main(model_name: str) -> None:
|
||||
model = RecordingModel(model_name)
|
||||
with tempfile.TemporaryDirectory(prefix="agents-skills-") as temp_dir:
|
||||
skills_root = Path(temp_dir).resolve() / "skills"
|
||||
_write_local_skill(skills_root)
|
||||
|
||||
agent = _build_agent(model, skills_root)
|
||||
client = UnixLocalSandboxClient()
|
||||
sandbox = await client.create(manifest=agent.default_manifest)
|
||||
|
||||
try:
|
||||
async with sandbox:
|
||||
result = Runner.run_streamed(
|
||||
agent,
|
||||
_initial_input(),
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
tracing_disabled=True,
|
||||
workflow_name="Sandbox capabilities smoke",
|
||||
),
|
||||
)
|
||||
await _print_stream_details(result)
|
||||
|
||||
tool_calls = [
|
||||
_tool_call_name(item)
|
||||
for item in result.new_items
|
||||
if isinstance(item, ToolCallItem)
|
||||
]
|
||||
tool_outputs = [
|
||||
item.output for item in result.new_items if isinstance(item, ToolCallOutputItem)
|
||||
]
|
||||
vision_outputs = [
|
||||
output for output in tool_outputs if isinstance(output, ToolOutputImage)
|
||||
]
|
||||
verification_text = await _read_workspace_text(sandbox, VERIFICATION_FILE)
|
||||
delete_file_exists = True
|
||||
try:
|
||||
handle = await sandbox.read(DELETE_FILE)
|
||||
except WorkspaceReadNotFoundError:
|
||||
delete_file_exists = False
|
||||
else:
|
||||
handle.close()
|
||||
|
||||
first_model_settings = model.first_model_settings
|
||||
if first_model_settings is None:
|
||||
raise RuntimeError("Model settings were not captured")
|
||||
extra_args = first_model_settings.extra_args or {}
|
||||
if extra_args.get("context_management") is None:
|
||||
raise RuntimeError(
|
||||
f"Compaction sampling params were not attached: {extra_args!r}"
|
||||
)
|
||||
|
||||
expected_tools = {
|
||||
"load_skill",
|
||||
"apply_patch",
|
||||
"exec_command",
|
||||
"view_image",
|
||||
}
|
||||
missing_tools = expected_tools - set(tool_calls)
|
||||
if missing_tools:
|
||||
raise RuntimeError(
|
||||
"Missing expected tool calls: "
|
||||
f"{sorted(missing_tools)}; observed tool calls: {tool_calls}"
|
||||
)
|
||||
|
||||
expected_verification = (
|
||||
"skill_loaded=true\n"
|
||||
"codename=atlas\n"
|
||||
"note_source=filesystem\n"
|
||||
"image_verified=true\n"
|
||||
)
|
||||
if verification_text.rstrip("\n") != expected_verification.rstrip("\n"):
|
||||
raise RuntimeError(
|
||||
"Verification file content mismatch:\n"
|
||||
f"expected={expected_verification!r}\n"
|
||||
f"actual={verification_text!r}"
|
||||
)
|
||||
|
||||
if expected_verification.strip() not in "\n".join(
|
||||
str(output) for output in tool_outputs
|
||||
):
|
||||
raise RuntimeError("Shell output did not include the verification file content")
|
||||
|
||||
if not vision_outputs:
|
||||
raise RuntimeError("Expected view_image to produce a ToolOutputImage")
|
||||
|
||||
if not all(
|
||||
isinstance(output.image_url, str) and output.image_url.startswith("data:image/")
|
||||
for output in vision_outputs
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Expected ToolOutputImage data URLs from view_image, got {vision_outputs!r}"
|
||||
)
|
||||
|
||||
if delete_file_exists:
|
||||
raise RuntimeError(f"Expected {DELETE_FILE.as_posix()} to be deleted")
|
||||
|
||||
print("=== Final summary ===")
|
||||
print("final_output:", result.final_output)
|
||||
print("tool_calls:", ", ".join(tool_calls))
|
||||
print("vision_outputs:", len(vision_outputs))
|
||||
print(f"compaction_threshold: {COMPACTION_THRESHOLD}")
|
||||
print(f"compaction_extra_args: {extra_args}")
|
||||
print(f"verification_file: {VERIFICATION_FILE.as_posix()}")
|
||||
print(f"deleted_file_absent: {not delete_file_exists}")
|
||||
print(verification_text, end="")
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
await model.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(main(args.model))
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Sandbox agent example using a dependency-injected remote snapshot client.
|
||||
|
||||
This demonstrates persisting a Unix-local sandbox workspace to S3 with `RemoteSnapshotSpec`,
|
||||
then resuming the session from the downloaded snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from agents import ModelSettings, Runner
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, RemoteSnapshotSpec, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
from agents.sandbox.session import Dependencies
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
S3_BUCKET_ENV_VAR = "S3_MOUNT_BUCKET"
|
||||
SNAPSHOT_OBJECT_PREFIX = "openai-agents-python/sandbox-snapshots"
|
||||
SNAPSHOT_CLIENT_DEPENDENCY_KEY = "examples.remote_snapshot.s3_client"
|
||||
SNAPSHOT_CHECK_PATH = Path("snapshot-check.txt")
|
||||
SNAPSHOT_CHECK_CONTENT = "remote snapshot round-trip ok\n"
|
||||
|
||||
|
||||
class S3SnapshotClient:
|
||||
"""Minimal S3 client adapter for `RemoteSnapshot`."""
|
||||
|
||||
def __init__(self, *, bucket: str, prefix: str) -> None:
|
||||
try:
|
||||
import boto3 # type: ignore[import-untyped]
|
||||
except Exception as exc: # pragma: no cover - optional local dependency
|
||||
raise SystemExit(
|
||||
"This example requires boto3 for S3 snapshot storage.\n"
|
||||
"Install it with: uv sync --extra s3"
|
||||
) from exc
|
||||
|
||||
self._bucket = bucket
|
||||
self._prefix = prefix.rstrip("/")
|
||||
self._s3 = boto3.client("s3")
|
||||
|
||||
def upload(self, snapshot_id: str, data: io.IOBase) -> None:
|
||||
self._s3.upload_fileobj(data, self._bucket, self._object_key(snapshot_id))
|
||||
|
||||
def download(self, snapshot_id: str) -> io.IOBase:
|
||||
buffer = io.BytesIO()
|
||||
self._s3.download_fileobj(self._bucket, self._object_key(snapshot_id), buffer)
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
def exists(self, snapshot_id: str) -> bool:
|
||||
from botocore.exceptions import ClientError # type: ignore[import-untyped]
|
||||
|
||||
try:
|
||||
self._s3.head_object(Bucket=self._bucket, Key=self._object_key(snapshot_id))
|
||||
except ClientError as exc:
|
||||
if exc.response.get("Error", {}).get("Code") in {"404", "NoSuchKey", "NotFound"}:
|
||||
return False
|
||||
raise
|
||||
return True
|
||||
|
||||
def _object_key(self, snapshot_id: str) -> str:
|
||||
return f"{self._prefix}/{snapshot_id}.tar"
|
||||
|
||||
|
||||
def _build_manifest() -> Manifest:
|
||||
return text_manifest(
|
||||
{
|
||||
"README.md": (
|
||||
"# Remote Snapshot Demo\n\n"
|
||||
"This workspace exists to show a sandbox session persisting its snapshot to S3.\n"
|
||||
),
|
||||
"status.md": (
|
||||
"# Status\n\n"
|
||||
"- The first run writes a snapshot check file into the workspace.\n"
|
||||
"- The resumed run verifies that the file came back from remote storage.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_agent(*, model: str, manifest: Manifest) -> SandboxAgent:
|
||||
return SandboxAgent(
|
||||
name="Remote Snapshot Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"Inspect the sandbox workspace before answering. Keep the response concise and "
|
||||
"mention the file names you used. "
|
||||
"Do not invent files or state. Only describe what is present in the workspace."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required"),
|
||||
)
|
||||
|
||||
|
||||
def _require_s3_bucket() -> str:
|
||||
bucket = os.environ.get(S3_BUCKET_ENV_VAR)
|
||||
if not bucket:
|
||||
raise SystemExit(f"{S3_BUCKET_ENV_VAR} must be set before running this example.")
|
||||
return bucket
|
||||
|
||||
|
||||
async def _verify_remote_snapshot_round_trip(*, model: str) -> None:
|
||||
manifest = _build_manifest()
|
||||
dependencies = Dependencies().bind_value(
|
||||
SNAPSHOT_CLIENT_DEPENDENCY_KEY,
|
||||
S3SnapshotClient(bucket=_require_s3_bucket(), prefix=SNAPSHOT_OBJECT_PREFIX),
|
||||
)
|
||||
client = UnixLocalSandboxClient(dependencies=dependencies)
|
||||
|
||||
sandbox = await client.create(
|
||||
manifest=manifest,
|
||||
snapshot=RemoteSnapshotSpec(client_dependency_key=SNAPSHOT_CLIENT_DEPENDENCY_KEY),
|
||||
options=None,
|
||||
)
|
||||
|
||||
try:
|
||||
await sandbox.start()
|
||||
await sandbox.write(SNAPSHOT_CHECK_PATH, io.BytesIO(SNAPSHOT_CHECK_CONTENT.encode("utf-8")))
|
||||
await sandbox.stop()
|
||||
finally:
|
||||
await sandbox.shutdown()
|
||||
|
||||
resumed_sandbox = await client.resume(sandbox.state)
|
||||
try:
|
||||
await resumed_sandbox.start()
|
||||
restored = await resumed_sandbox.read(SNAPSHOT_CHECK_PATH)
|
||||
restored_text = restored.read()
|
||||
if isinstance(restored_text, bytes):
|
||||
restored_text = restored_text.decode("utf-8")
|
||||
if restored_text != SNAPSHOT_CHECK_CONTENT:
|
||||
raise RuntimeError(
|
||||
"Remote snapshot resume verification failed: "
|
||||
f"expected {SNAPSHOT_CHECK_CONTENT!r}, got {restored_text!r}"
|
||||
)
|
||||
finally:
|
||||
await resumed_sandbox.aclose()
|
||||
|
||||
agent = _build_agent(model=model, manifest=manifest)
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
"Summarize this workspace in one sentence.",
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(client=client),
|
||||
workflow_name="Remote snapshot sandbox example",
|
||||
),
|
||||
)
|
||||
|
||||
print("snapshot round-trip ok (s3)")
|
||||
print(result.final_output)
|
||||
|
||||
|
||||
async def main(model: str) -> None:
|
||||
await _verify_remote_snapshot_round_trip(model=model)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.6-sol", help="Model name to use.")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(main(args.model))
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Show how a sandbox agent can combine three tool sources in one run.
|
||||
|
||||
This example gives the model:
|
||||
|
||||
1. A sandbox workspace to inspect with the shared shell capability.
|
||||
2. A normal local function tool for approval routing.
|
||||
3. A local stdio MCP server for reference policy lookups.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from agents import Runner, function_tool
|
||||
from agents.mcp import MCPServerStdio
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest, tool_call_name
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
DEFAULT_QUESTION = (
|
||||
"Review this enterprise renewal request. Tell me who needs to approve the discount, "
|
||||
"whether security review is still open, and the most important note for the account team. "
|
||||
"Confirm the approval and security answers against the reference policy server before you respond."
|
||||
)
|
||||
|
||||
|
||||
@function_tool
|
||||
def get_discount_approval_path(discount_percent: int) -> str:
|
||||
"""Return the approver required for a proposed discount percentage."""
|
||||
if discount_percent <= 10:
|
||||
return "The account executive can approve discounts up to 10 percent."
|
||||
if discount_percent <= 15:
|
||||
return "The regional sales director must approve discounts from 11 to 15 percent."
|
||||
return "Finance and the regional sales director must both approve discounts above 15 percent."
|
||||
|
||||
|
||||
async def main(model: str, question: str) -> None:
|
||||
# This manifest becomes the workspace that the sandbox agent can inspect.
|
||||
manifest = text_manifest(
|
||||
{
|
||||
"renewal_request.md": (
|
||||
"# Renewal request\n\n"
|
||||
"- Customer: Contoso Manufacturing.\n"
|
||||
"- Requested discount: 14 percent.\n"
|
||||
"- Renewal term: 12 months.\n"
|
||||
"- Requested close date: March 28.\n"
|
||||
),
|
||||
"account_notes.md": (
|
||||
"# Account notes\n\n"
|
||||
"- The customer expanded usage in two plants this quarter.\n"
|
||||
"- Security review for the new data export workflow was opened last week.\n"
|
||||
"- Procurement wants a final approval map before they send the order form.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# The reference MCP server is another local process. The agent can call its tools alongside
|
||||
# the sandbox shell tool and the normal Python function tool.
|
||||
async with MCPServerStdio(
|
||||
name="Reference Policy Server",
|
||||
params={
|
||||
"command": sys.executable,
|
||||
"args": [
|
||||
str(Path(__file__).resolve().parent / "misc" / "reference_policy_mcp_server.py")
|
||||
],
|
||||
},
|
||||
) as server:
|
||||
agent = SandboxAgent(
|
||||
name="Renewal Review Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You review renewal requests. Inspect the packet, use "
|
||||
"`get_discount_approval_path` for discount routing, and use the MCP reference "
|
||||
"policy server when you need confirmation. Before you answer, you must call "
|
||||
"`get_discount_approval_path` and at least one MCP policy tool. "
|
||||
"Keep the answer concise and business-ready. Mention which policy topic you "
|
||||
"confirmed through MCP."
|
||||
),
|
||||
default_manifest=manifest,
|
||||
tools=[get_discount_approval_path],
|
||||
mcp_servers=[server],
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
)
|
||||
|
||||
result = await Runner.run(
|
||||
agent,
|
||||
question,
|
||||
run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())),
|
||||
)
|
||||
tool_names: list[str] = []
|
||||
for item in result.new_items:
|
||||
if getattr(item, "type", None) != "tool_call_item":
|
||||
continue
|
||||
name = tool_call_name(item.raw_item)
|
||||
if name:
|
||||
tool_names.append(name)
|
||||
if tool_names:
|
||||
print(f"[tools used] {', '.join(tool_names)}")
|
||||
print(result.final_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.6-sol", 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,206 @@
|
||||
"""
|
||||
Show how sandbox agents can be exposed as tools to a normal orchestrator.
|
||||
|
||||
Each sandbox reviewer gets its own isolated workspace. The outer orchestrator
|
||||
does not inspect files directly. It calls the reviewers as tools and combines
|
||||
their outputs with a normal Python function tool.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from openai.types.shared import Reasoning
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agents import Agent, ModelSettings, Runner, function_tool
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from examples.sandbox.misc.example_support import text_manifest, tool_call_name
|
||||
from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability
|
||||
|
||||
DEFAULT_QUESTION = (
|
||||
"Review the Acme renewal materials and give me a short recommendation for the deal desk. "
|
||||
"Include pricing risk, rollout risk, and the most important next step."
|
||||
)
|
||||
|
||||
|
||||
class PricingPacketReview(BaseModel):
|
||||
requested_discount_percent: int = Field(
|
||||
description="Exact requested discount percentage from pricing_summary.md."
|
||||
)
|
||||
requested_term_months: int = Field(
|
||||
description="Exact requested renewal term in months from pricing_summary.md."
|
||||
)
|
||||
pricing_risk: Literal["low", "medium", "high"]
|
||||
summary: str = Field(description="Short pricing risk summary grounded in the reviewed files.")
|
||||
recommended_next_step: str = Field(
|
||||
description="Most important commercial next step for the deal desk."
|
||||
)
|
||||
evidence_files: list[str] = Field(
|
||||
description="File names that support the review.", min_length=1
|
||||
)
|
||||
|
||||
|
||||
class RolloutRiskReview(BaseModel):
|
||||
rollout_risk: Literal["low", "medium", "high"]
|
||||
summary: str = Field(description="Short rollout risk summary grounded in the reviewed files.")
|
||||
blockers: list[str] = Field(description="Concrete rollout blockers from the reviewed files.")
|
||||
recommended_next_step: str = Field(
|
||||
description="Most important delivery next step for the deal desk."
|
||||
)
|
||||
evidence_files: list[str] = Field(
|
||||
description="File names that support the review.", min_length=1
|
||||
)
|
||||
|
||||
|
||||
async def _structured_tool_output_extractor(result) -> str:
|
||||
final_output = result.final_output
|
||||
if isinstance(final_output, BaseModel):
|
||||
return json.dumps(final_output.model_dump(mode="json"), sort_keys=True)
|
||||
return str(final_output)
|
||||
|
||||
|
||||
@function_tool
|
||||
def get_discount_approval_rule(discount_percent: int) -> str:
|
||||
"""Return the internal approver required for a proposed discount."""
|
||||
if discount_percent <= 10:
|
||||
return "Discounts up to 10 percent can be approved by the account executive."
|
||||
if discount_percent <= 15:
|
||||
return "Discounts from 11 to 15 percent require regional sales director approval."
|
||||
return "Discounts above 15 percent require finance and regional sales director approval."
|
||||
|
||||
|
||||
async def main(model: str, question: str) -> None:
|
||||
# This manifest is visible only to the pricing reviewer.
|
||||
pricing_manifest = text_manifest(
|
||||
{
|
||||
"pricing_summary.md": (
|
||||
"# Pricing summary\n\n"
|
||||
"- Current annual contract: $220,000.\n"
|
||||
"- Requested renewal term: 24 months.\n"
|
||||
"- Requested discount: 15 percent.\n"
|
||||
"- Account executive target discount band: 8 to 10 percent.\n"
|
||||
),
|
||||
"commercial_notes.md": (
|
||||
"# Commercial notes\n\n"
|
||||
"- The customer expanded from 120 to 170 paid seats in the last 6 months.\n"
|
||||
"- Procurement asked for one final concession to close before quarter end.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# This separate manifest is visible only to the rollout reviewer.
|
||||
rollout_manifest = text_manifest(
|
||||
{
|
||||
"rollout_plan.md": (
|
||||
"# Rollout plan\n\n"
|
||||
"- Customer wants a 30-day rollout for three new regional teams.\n"
|
||||
"- Regional admins have not completed training yet.\n"
|
||||
"- SSO migration is scheduled for the second week of the rollout.\n"
|
||||
),
|
||||
"support_history.md": (
|
||||
"# Support history\n\n"
|
||||
"- Two high-priority onboarding tickets were closed in the last quarter.\n"
|
||||
"- No open production incidents.\n"
|
||||
"- Customer success manager asked for a phased launch if the contract closes.\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
pricing_agent = SandboxAgent(
|
||||
name="Pricing Packet Reviewer",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You inspect renewal pricing documents and return a structured commercial review. "
|
||||
"Inspect the files before answering and extract the exact requested discount percent "
|
||||
"and renewal term from pricing_summary.md. "
|
||||
"Use the shell tool before answering. requested_discount_percent must match the exact "
|
||||
"integer in pricing_summary.md. requested_term_months must match the exact renewal "
|
||||
"term from pricing_summary.md. Do not introduce any facts, incidents, or numbers that "
|
||||
"are not present in pricing_summary.md or commercial_notes.md. evidence_files must "
|
||||
"list only files you actually inspected."
|
||||
),
|
||||
default_manifest=pricing_manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required", reasoning=Reasoning(effort="none")),
|
||||
output_type=PricingPacketReview,
|
||||
)
|
||||
rollout_agent = SandboxAgent(
|
||||
name="Rollout Risk Reviewer",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You inspect rollout plans and return a structured delivery review. Inspect the files "
|
||||
"before answering and keep the output tightly grounded in the rollout documents. "
|
||||
"Use the shell tool before answering. blockers must only contain issues that appear in "
|
||||
"rollout_plan.md or support_history.md. Do not introduce any extra numbers, incidents, "
|
||||
"or stakeholders beyond those files. evidence_files must list only files you actually "
|
||||
"inspected."
|
||||
),
|
||||
default_manifest=rollout_manifest,
|
||||
capabilities=[WorkspaceShellCapability()],
|
||||
model_settings=ModelSettings(tool_choice="required", reasoning=Reasoning(effort="none")),
|
||||
output_type=RolloutRiskReview,
|
||||
)
|
||||
|
||||
# Each sandbox-backed tool gets its own run configuration so the workspaces stay isolated.
|
||||
pricing_run_config = RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()))
|
||||
rollout_run_config = RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()))
|
||||
|
||||
orchestrator = Agent(
|
||||
name="Revenue Operations Coordinator",
|
||||
model=model,
|
||||
instructions=(
|
||||
"You coordinate renewal reviews. Before answering, you must use all three tools: "
|
||||
"`review_pricing_packet`, `review_rollout_risk`, and `get_discount_approval_rule`. "
|
||||
"The review tools return JSON. Use the exact `requested_discount_percent` field from "
|
||||
"`review_pricing_packet` when calling `get_discount_approval_rule`. In the final "
|
||||
"recommendation, use only facts and numbers that appear in the tool outputs, and do "
|
||||
"not add any extra incidents, price points, or contract terms."
|
||||
),
|
||||
model_settings=ModelSettings(tool_choice="required", reasoning=Reasoning(effort="none")),
|
||||
tools=[
|
||||
pricing_agent.as_tool(
|
||||
tool_name="review_pricing_packet",
|
||||
tool_description="Inspect the pricing packet and summarize commercial risk.",
|
||||
custom_output_extractor=_structured_tool_output_extractor,
|
||||
run_config=pricing_run_config,
|
||||
max_turns=6,
|
||||
),
|
||||
rollout_agent.as_tool(
|
||||
tool_name="review_rollout_risk",
|
||||
tool_description="Inspect the rollout packet and summarize implementation risk.",
|
||||
custom_output_extractor=_structured_tool_output_extractor,
|
||||
run_config=rollout_run_config,
|
||||
max_turns=6,
|
||||
),
|
||||
get_discount_approval_rule,
|
||||
],
|
||||
)
|
||||
|
||||
result = await Runner.run(orchestrator, question, max_turns=8)
|
||||
tool_names = [
|
||||
tool_call_name(item.raw_item)
|
||||
for item in result.new_items
|
||||
if getattr(item, "type", None) == "tool_call_item"
|
||||
]
|
||||
if tool_names:
|
||||
print(f"[tools used] {', '.join(tool_names)}")
|
||||
print(result.final_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.6-sol", 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,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from openai.types.responses import ResponseTextDeltaEvent
|
||||
|
||||
from agents import Runner
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.capabilities import Capabilities, Skills
|
||||
from agents.sandbox.entries import Dir, GitRepo, LocalFile
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
|
||||
DATA_PATH = Path(__file__).resolve().parent / "data"
|
||||
W2_PATH = DATA_PATH / "sample_w2.pdf"
|
||||
FORM_1040_PATH = DATA_PATH / "f1040.pdf"
|
||||
DEFAULT_IMAGE = "tax-prep:latest"
|
||||
DEFAULT_SKILLS_REPO = "sdcoffey/tax-prep-skills"
|
||||
DEFAULT_SKILLS_REF = "main"
|
||||
DEFAULT_QUESTION = "Please generate a 1040 for filing year 2025."
|
||||
|
||||
INSTRUCTIONS = """
|
||||
You are a federal tax filing agent. Your job is to compute year-end taxes and
|
||||
produce a filled-out Form 1040 for the specified tax year using the user's
|
||||
provided documents. Use only the information in the supplied files. If required
|
||||
data is missing or unclear, ask follow-up questions or note explicit
|
||||
assumptions. Save the finalized, filled PDF in the `output/` directory and
|
||||
provide a short summary of key amounts such as income, deductions, tax, and
|
||||
refund or amount due.
|
||||
|
||||
This is a demo, so assume the following unless the workspace says otherwise:
|
||||
1. Filing status is single.
|
||||
2. SSN is 123-45-6789.
|
||||
3. Date of birth is 1991-01-01.
|
||||
4. There are no other income documents.
|
||||
5. If a minor data point is still needed, make up a clearly synthetic test value.
|
||||
|
||||
Use the `federal-tax-prep` skill to accomplish this task.
|
||||
""".strip()
|
||||
|
||||
|
||||
def _require_docker_dependency():
|
||||
try:
|
||||
from docker import from_env as docker_from_env # type: ignore[import-untyped]
|
||||
except Exception as exc: # pragma: no cover - import path depends on local Docker setup
|
||||
raise SystemExit(
|
||||
"Docker-backed runs require the Docker SDK.\n"
|
||||
"Install the repo dependencies with: make sync"
|
||||
) from exc
|
||||
|
||||
from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions
|
||||
|
||||
return docker_from_env, DockerSandboxClient, DockerSandboxClientOptions
|
||||
|
||||
|
||||
def _build_manifest() -> Manifest:
|
||||
return Manifest(
|
||||
entries={
|
||||
"taxpayer_data": Dir(
|
||||
children={"sample_w2.pdf": LocalFile(src=W2_PATH)},
|
||||
description="Taxpayer income documents such as W-2s and 1099s.",
|
||||
),
|
||||
"reference_forms": Dir(
|
||||
children={"f1040.pdf": LocalFile(src=FORM_1040_PATH)},
|
||||
description="Blank tax forms the agent can use as templates.",
|
||||
),
|
||||
"output": Dir(description="Write finalized tax documents here."),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_agent(*, model: str, skills_repo: str, skills_ref: str) -> SandboxAgent:
|
||||
return SandboxAgent(
|
||||
name="Tax Prep Assistant",
|
||||
model=model,
|
||||
instructions=(
|
||||
INSTRUCTIONS + "\n\n"
|
||||
"Inspect the workspace before answering. Keep final explanations concise, and make "
|
||||
"sure the final filled files are actually written into `output/`."
|
||||
),
|
||||
default_manifest=_build_manifest(),
|
||||
capabilities=Capabilities.default()
|
||||
+ [
|
||||
Skills(
|
||||
from_=GitRepo(repo=skills_repo, ref=skills_ref),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _copy_output_dir(
|
||||
*,
|
||||
session,
|
||||
destination_root: Path,
|
||||
) -> list[Path]:
|
||||
destination_root.mkdir(parents=True, exist_ok=True)
|
||||
remote_output_root = session.normalize_path("output")
|
||||
|
||||
pending_dirs = [remote_output_root]
|
||||
copied_files: list[Path] = []
|
||||
while pending_dirs:
|
||||
current_dir = pending_dirs.pop()
|
||||
for entry in await session.ls(current_dir):
|
||||
entry_path = Path(entry.path)
|
||||
if entry.is_dir():
|
||||
pending_dirs.append(entry_path)
|
||||
continue
|
||||
|
||||
relative_path = entry_path.relative_to(remote_output_root)
|
||||
local_path = destination_root / relative_path
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handle = await session.read(entry_path)
|
||||
try:
|
||||
payload = handle.read()
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
if isinstance(payload, str):
|
||||
local_path.write_text(payload, encoding="utf-8")
|
||||
else:
|
||||
local_path.write_bytes(bytes(payload))
|
||||
copied_files.append(local_path)
|
||||
|
||||
return copied_files
|
||||
|
||||
|
||||
async def _run_turn(
|
||||
*,
|
||||
agent: SandboxAgent,
|
||||
input_items: list[TResponseInputItem],
|
||||
run_config: RunConfig,
|
||||
) -> list[TResponseInputItem]:
|
||||
stream_result = Runner.run_streamed(agent, input_items, run_config=run_config)
|
||||
saw_text_delta = False
|
||||
async for event in stream_result.stream_events():
|
||||
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
|
||||
if not saw_text_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
saw_text_delta = True
|
||||
print(event.data.delta, end="", flush=True)
|
||||
continue
|
||||
|
||||
if event.type == "run_item_stream_event" and event.name == "tool_called":
|
||||
raw_item = getattr(event.item, "raw_item", None)
|
||||
tool_name = ""
|
||||
if isinstance(raw_item, dict):
|
||||
tool_name = cast(str, raw_item.get("name") or raw_item.get("type") or "")
|
||||
else:
|
||||
tool_name = cast(
|
||||
str,
|
||||
getattr(raw_item, "name", None) or getattr(raw_item, "type", None) or "",
|
||||
)
|
||||
if tool_name:
|
||||
if saw_text_delta:
|
||||
print()
|
||||
saw_text_delta = False
|
||||
print(f"[tool call] {tool_name}")
|
||||
|
||||
if saw_text_delta:
|
||||
print()
|
||||
|
||||
return stream_result.to_input_list()
|
||||
|
||||
|
||||
async def main(
|
||||
*,
|
||||
model: str,
|
||||
image: str,
|
||||
question: str,
|
||||
output_dir: Path,
|
||||
skills_repo: str,
|
||||
skills_ref: str,
|
||||
) -> None:
|
||||
docker_from_env, DockerSandboxClient, DockerSandboxClientOptions = _require_docker_dependency()
|
||||
agent = _build_agent(model=model, skills_repo=skills_repo, skills_ref=skills_ref)
|
||||
client = DockerSandboxClient(docker_from_env())
|
||||
sandbox = await client.create(
|
||||
manifest=agent.default_manifest,
|
||||
options=DockerSandboxClientOptions(image=image),
|
||||
)
|
||||
|
||||
run_config = RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
workflow_name="Sandbox tax prep demo",
|
||||
)
|
||||
|
||||
conversation: list[TResponseInputItem] = [{"role": "user", "content": question}]
|
||||
|
||||
try:
|
||||
async with sandbox:
|
||||
conversation = await _run_turn(
|
||||
agent=agent,
|
||||
input_items=conversation,
|
||||
run_config=run_config,
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
additional_input = input("> ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
|
||||
conversation.append({"role": "user", "content": additional_input})
|
||||
conversation = await _run_turn(
|
||||
agent=agent,
|
||||
input_items=conversation,
|
||||
run_config=run_config,
|
||||
)
|
||||
|
||||
copied_files = await _copy_output_dir(session=sandbox, destination_root=output_dir)
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
|
||||
print(f"\nCopied {len(copied_files)} file(s) to {output_dir}")
|
||||
for copied_file in copied_files:
|
||||
print(copied_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default="gpt-5.6-sol", help="Model name to use.")
|
||||
parser.add_argument("--image", default=DEFAULT_IMAGE, help="Docker image for the sandbox.")
|
||||
parser.add_argument("--question", default=DEFAULT_QUESTION, help="Prompt to send to the agent.")
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default="tax-prep-results",
|
||||
help="Local directory where files from sandbox output/ will be copied.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills-repo",
|
||||
default=DEFAULT_SKILLS_REPO,
|
||||
help="GitHub repo in owner/name form for the skills bundle.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills-ref",
|
||||
default=DEFAULT_SKILLS_REF,
|
||||
help="Git ref for the skills bundle.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
model=args.model,
|
||||
image=args.image,
|
||||
question=args.question,
|
||||
output_dir=Path(args.output_dir).resolve(),
|
||||
skills_repo=args.skills_repo,
|
||||
skills_ref=args.skills_ref,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.14-slim
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a /uv /bin/uv
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
poppler-utils \
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN uv pip install --system --no-cache-dir --index-strategy first-index --exclude-newer "7 days" pypdf
|
||||
|
||||
WORKDIR /workspace
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
"""Generate the synthetic dataroom fixture files."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def pdf_escape(text: str) -> str:
|
||||
return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||
|
||||
|
||||
def write_plain_pdf(path: Path, lines: list[str]) -> None:
|
||||
content_lines = ["BT", "/F1 11 Tf", "50 760 Td", "14 TL"]
|
||||
for index, line in enumerate(lines):
|
||||
operator = "Tj" if index == 0 else "T* Tj"
|
||||
content_lines.append(f"({pdf_escape(line)}) {operator}")
|
||||
content_lines.append("ET")
|
||||
stream = "\n".join(content_lines).encode("utf-8")
|
||||
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
||||
b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
b"<< /Length "
|
||||
+ str(len(stream)).encode("ascii")
|
||||
+ b" >>\nstream\n"
|
||||
+ stream
|
||||
+ b"\nendstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
|
||||
pdf = bytearray(b"%PDF-1.4\n")
|
||||
offsets = [0]
|
||||
for index, body in enumerate(objects, start=1):
|
||||
offsets.append(len(pdf))
|
||||
pdf.extend(f"{index} 0 obj\n".encode("ascii"))
|
||||
pdf.extend(body)
|
||||
pdf.extend(b"\nendobj\n")
|
||||
|
||||
xref_offset = len(pdf)
|
||||
pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
|
||||
pdf.extend(b"0000000000 65535 f \n")
|
||||
for offset in offsets[1:]:
|
||||
pdf.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
|
||||
pdf.extend(
|
||||
(
|
||||
"trailer\n"
|
||||
f"<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
|
||||
"startxref\n"
|
||||
f"{xref_offset}\n"
|
||||
"%%EOF\n"
|
||||
).encode("ascii")
|
||||
)
|
||||
path.write_bytes(pdf)
|
||||
|
||||
|
||||
def write_financial_pdf(path: Path, title: str, lines: list[str], rows: list[list[str]]) -> None:
|
||||
write_plain_pdf(path, [title, *lines, *(" | ".join(row) for row in rows)])
|
||||
|
||||
|
||||
def write_fixture_text(data_dir: Path, filename: str, content: str) -> None:
|
||||
(data_dir / filename).write_text(content.strip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
data_dir = Path(__file__).resolve().parent
|
||||
write_fixture_text(
|
||||
data_dir,
|
||||
"10-k-mdna-overview.txt",
|
||||
"""
|
||||
UNITED STATES
|
||||
SECURITIES AND EXCHANGE COMMISSION
|
||||
Washington, D.C. 20549
|
||||
|
||||
FORM 10-K
|
||||
ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
|
||||
For the fiscal year ended December 31, 2025
|
||||
|
||||
HelioCart, Inc.
|
||||
|
||||
PART II
|
||||
Item 7. Management's Discussion and Analysis of Financial Condition and Results of Operations
|
||||
|
||||
Revenue for fiscal 2025 was $1,284 million, compared with $1,008 million in fiscal 2024.
|
||||
The increase was driven primarily by Platform revenue growth from merchant fraud
|
||||
decisioning and payment orchestration workloads.
|
||||
|
||||
Gross margin improved to 71.4% in fiscal 2025 from 68.2% in fiscal 2024 because a higher
|
||||
mix of transaction volume ran on lower-cost model serving infrastructure.
|
||||
|
||||
Operating income was $186 million in fiscal 2025, compared with $118 million in fiscal 2024.
|
||||
Management uses "net revenue" and "revenue" interchangeably in this MD&A section.
|
||||
""",
|
||||
)
|
||||
write_fixture_text(
|
||||
data_dir,
|
||||
"10-k-mdna-liquidity.txt",
|
||||
"""
|
||||
UNITED STATES
|
||||
SECURITIES AND EXCHANGE COMMISSION
|
||||
Washington, D.C. 20549
|
||||
|
||||
FORM 10-K
|
||||
ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
|
||||
For the fiscal year ended December 31, 2025
|
||||
|
||||
HelioCart, Inc.
|
||||
|
||||
PART II
|
||||
Item 7. Management's Discussion and Analysis of Financial Condition and Results of Operations
|
||||
|
||||
Liquidity and capital resources. Net cash provided by operating activities was $248 million
|
||||
in fiscal 2025, compared with $192 million in fiscal 2024, primarily because of higher
|
||||
cash collections and improved operating margins.
|
||||
|
||||
Capital expenditures were $86 million in fiscal 2025 and $73 million in fiscal 2024.
|
||||
Free cash flow, a non-GAAP measure defined as operating cash flow less capital
|
||||
expenditures, was $162 million in fiscal 2025 and $119 million in fiscal 2024.
|
||||
""",
|
||||
)
|
||||
write_fixture_text(
|
||||
data_dir,
|
||||
"10-k-note-segments.txt",
|
||||
"""
|
||||
UNITED STATES
|
||||
SECURITIES AND EXCHANGE COMMISSION
|
||||
Washington, D.C. 20549
|
||||
|
||||
FORM 10-K
|
||||
ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
|
||||
For the fiscal year ended December 31, 2025
|
||||
|
||||
HelioCart, Inc.
|
||||
|
||||
PART II
|
||||
Item 8. Financial Statements and Supplementary Data
|
||||
|
||||
Note 4. Revenue by reportable segment
|
||||
|
||||
Platform segment revenue was $942 million in fiscal 2025 and $711 million in fiscal 2024.
|
||||
Services segment revenue was $342 million in fiscal 2025 and $297 million in fiscal 2024.
|
||||
|
||||
Management refers to Platform revenue as "Subscription and transaction platform revenue"
|
||||
in some tables; treat that label as the same Platform segment revenue metric.
|
||||
""",
|
||||
)
|
||||
write_fixture_text(
|
||||
data_dir,
|
||||
"10-k-note-geography.txt",
|
||||
"""
|
||||
UNITED STATES
|
||||
SECURITIES AND EXCHANGE COMMISSION
|
||||
Washington, D.C. 20549
|
||||
|
||||
FORM 10-K
|
||||
ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
|
||||
For the fiscal year ended December 31, 2025
|
||||
|
||||
HelioCart, Inc.
|
||||
|
||||
PART II
|
||||
Item 8. Financial Statements and Supplementary Data
|
||||
|
||||
Note 5. Revenue by geography
|
||||
|
||||
Americas revenue was $764 million in fiscal 2025, EMEA revenue was $343 million,
|
||||
and APAC revenue was $177 million. Those regional line items reconcile to the
|
||||
company-wide revenue figure disclosed in MD&A.
|
||||
""",
|
||||
)
|
||||
write_fixture_text(
|
||||
data_dir,
|
||||
"10-k-note-balance-sheet.txt",
|
||||
"""
|
||||
UNITED STATES
|
||||
SECURITIES AND EXCHANGE COMMISSION
|
||||
Washington, D.C. 20549
|
||||
|
||||
FORM 10-K
|
||||
ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934
|
||||
For the fiscal year ended December 31, 2025
|
||||
|
||||
HelioCart, Inc.
|
||||
|
||||
PART II
|
||||
Item 8. Financial Statements and Supplementary Data
|
||||
|
||||
Note 7. Selected balance sheet metrics
|
||||
|
||||
Cash and cash equivalents were $422 million as of December 31, 2025, compared with
|
||||
$351 million as of December 31, 2024. Deferred revenue was $402 million as of
|
||||
December 31, 2025, compared with $337 million as of December 31, 2024.
|
||||
""",
|
||||
)
|
||||
|
||||
write_financial_pdf(
|
||||
data_dir / "10-k-statements-of-operations.pdf",
|
||||
"Consolidated Statements of Operations",
|
||||
[
|
||||
"The table below presents annual operating results for fiscal 2025 and fiscal 2024.",
|
||||
"Revenue and net revenue refer to the same top-line measure for this synthetic filing.",
|
||||
],
|
||||
[
|
||||
["Metric", "FY2025", "FY2024"],
|
||||
["Net revenue", "1,284", "1,008"],
|
||||
["Gross profit", "917", "687"],
|
||||
["Operating income", "186", "118"],
|
||||
],
|
||||
)
|
||||
write_financial_pdf(
|
||||
data_dir / "10-k-balance-sheets.pdf",
|
||||
"Consolidated Balance Sheets",
|
||||
[
|
||||
"The table below presents selected balance sheet amounts as of December 31, 2025 and 2024.",
|
||||
"Amounts are shown in USD millions.",
|
||||
],
|
||||
[
|
||||
["Metric", "2025", "2024"],
|
||||
["Cash and cash equivalents", "422", "351"],
|
||||
["Accounts receivable", "211", "187"],
|
||||
["Deferred revenue", "402", "337"],
|
||||
],
|
||||
)
|
||||
write_financial_pdf(
|
||||
data_dir / "10-k-statements-of-cash-flows.pdf",
|
||||
"Consolidated Statements of Cash Flows",
|
||||
[
|
||||
"The table below presents selected annual cash flow metrics for fiscal 2025 and 2024.",
|
||||
"Net cash provided by operating activities is also described as operating cash flow in MD&A.",
|
||||
],
|
||||
[
|
||||
["Metric", "FY2025", "FY2024"],
|
||||
["Net cash provided by operating activities", "248", "192"],
|
||||
["Capital expenditures", "86", "73"],
|
||||
["Free cash flow", "162", "119"],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
# Dataroom metric extract
|
||||
|
||||
## Goal
|
||||
|
||||
Extract financial metrics from a synthetic 10-K packet, write the resulting table as CSV or JSONL, then validate the generated artifact with a deterministic eval script.
|
||||
|
||||
The packet uses synthetic company data, but the source docs are formatted as annual-report excerpts with 10-K `Part II, Item 7` MD&A sections and `Part II, Item 8` financial statement sections.
|
||||
|
||||
## Why this is valuable
|
||||
|
||||
This demo shows a single-pass structured extraction pattern: a sandbox agent reads messy filing documents and emits typed financial rows, then a separate host-side eval script checks the artifact. The wrapper does not repair or deduplicate model output after the fact; if the row set is wrong, `evals.py` fails and you iterate on the prompt or fixture data instead.
|
||||
|
||||
## Setup
|
||||
|
||||
Run the fixture generator and then the Unix-local example from the repository root. Set `OPENAI_API_KEY` in your shell environment before running the example.
|
||||
|
||||
```bash
|
||||
uv run python examples/sandbox/tutorials/data/dataroom/setup.py
|
||||
uv run python examples/sandbox/tutorials/dataroom_metric_extract/main.py --output-format csv
|
||||
uv run python examples/sandbox/tutorials/dataroom_metric_extract/evals.py --artifact-path examples/sandbox/tutorials/dataroom_metric_extract/output/financial_metrics.csv
|
||||
```
|
||||
|
||||
After the initial extraction, the demo keeps the sandbox session open for Rich-rendered follow-up prompts before writing the final artifact. Pass `--no-interactive` for a one-shot run.
|
||||
|
||||
To run extraction in Docker, build the shared tutorial image once and add `--docker`
|
||||
to `main.py`:
|
||||
|
||||
```bash
|
||||
docker build --tag sandbox-tutorials:latest examples/sandbox/tutorials
|
||||
uv run python examples/sandbox/tutorials/dataroom_metric_extract/main.py --docker --output-format csv
|
||||
uv run python examples/sandbox/tutorials/dataroom_metric_extract/evals.py --artifact-path examples/sandbox/tutorials/dataroom_metric_extract/output/financial_metrics.csv
|
||||
```
|
||||
|
||||
## Expected artifacts
|
||||
|
||||
- `output/financial_metrics.csv`
|
||||
- `output/financial_metrics.jsonl`
|
||||
|
||||
## Demo shape
|
||||
|
||||
- Inputs: the shared SEC fixture packet in `examples/sandbox/tutorials/data/dataroom/`.
|
||||
- Runtime primitives: sandbox-local bash/file search plus typed agent outputs.
|
||||
- Workflow: a fixed single-step pipeline where the sandbox extractor emits `FinancialMetricBatch`; no handoff is needed. `main.py` writes the selected artifact format, and `evals.py` validates that artifact in a separate step.
|
||||
- Scratch space: the extractor may use `scratchpad/` for interim notes, but only the selected `output/financial_metrics.*` artifact is part of the final contract.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, TypeAlias
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
if TYPE_CHECKING or __package__:
|
||||
from .schemas import FinancialMetric, FinancialMetricBatch
|
||||
else:
|
||||
from schemas import FinancialMetric, FinancialMetricBatch
|
||||
|
||||
MetricKey: TypeAlias = tuple[str, str, str, str | None]
|
||||
|
||||
EXPECTED_SOURCE_METADATA: dict[str, str] = {
|
||||
"data/10-k-mdna-overview.txt": (
|
||||
"Part II, Item 7. Management's Discussion and Analysis of Financial Condition and "
|
||||
"Results of Operations"
|
||||
),
|
||||
"data/10-k-mdna-liquidity.txt": (
|
||||
"Part II, Item 7. Management's Discussion and Analysis of Financial Condition and "
|
||||
"Results of Operations"
|
||||
),
|
||||
"data/10-k-note-segments.txt": ("Part II, Item 8. Financial Statements and Supplementary Data"),
|
||||
"data/10-k-note-geography.txt": (
|
||||
"Part II, Item 8. Financial Statements and Supplementary Data"
|
||||
),
|
||||
"data/10-k-note-balance-sheet.txt": (
|
||||
"Part II, Item 8. Financial Statements and Supplementary Data"
|
||||
),
|
||||
"data/10-k-statements-of-operations.pdf": (
|
||||
"Part II, Item 8. Financial Statements and Supplementary Data"
|
||||
),
|
||||
"data/10-k-balance-sheets.pdf": (
|
||||
"Part II, Item 8. Financial Statements and Supplementary Data"
|
||||
),
|
||||
"data/10-k-statements-of-cash-flows.pdf": (
|
||||
"Part II, Item 8. Financial Statements and Supplementary Data"
|
||||
),
|
||||
}
|
||||
|
||||
EXPECTED_ROWS: dict[MetricKey, tuple[float, str]] = {
|
||||
("data/10-k-mdna-overview.txt", "Revenue", "FY2025", None): (1284.0, "USD millions"),
|
||||
("data/10-k-mdna-overview.txt", "Revenue", "FY2024", None): (1008.0, "USD millions"),
|
||||
("data/10-k-mdna-overview.txt", "Gross margin", "FY2025", None): (71.4, "percent"),
|
||||
("data/10-k-mdna-overview.txt", "Gross margin", "FY2024", None): (68.2, "percent"),
|
||||
("data/10-k-mdna-overview.txt", "Operating income", "FY2025", None): (186.0, "USD millions"),
|
||||
("data/10-k-mdna-overview.txt", "Operating income", "FY2024", None): (118.0, "USD millions"),
|
||||
(
|
||||
"data/10-k-mdna-liquidity.txt",
|
||||
"Net cash provided by operating activities",
|
||||
"FY2025",
|
||||
None,
|
||||
): (248.0, "USD millions"),
|
||||
(
|
||||
"data/10-k-mdna-liquidity.txt",
|
||||
"Net cash provided by operating activities",
|
||||
"FY2024",
|
||||
None,
|
||||
): (192.0, "USD millions"),
|
||||
("data/10-k-mdna-liquidity.txt", "Capital expenditures", "FY2025", None): (
|
||||
86.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-mdna-liquidity.txt", "Capital expenditures", "FY2024", None): (
|
||||
73.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-mdna-liquidity.txt", "Free cash flow", "FY2025", None): (
|
||||
162.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-mdna-liquidity.txt", "Free cash flow", "FY2024", None): (
|
||||
119.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-note-segments.txt", "Platform segment revenue", "FY2025", "Platform"): (
|
||||
942.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-note-segments.txt", "Platform segment revenue", "FY2024", "Platform"): (
|
||||
711.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-note-segments.txt", "Services segment revenue", "FY2025", "Services"): (
|
||||
342.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-note-segments.txt", "Services segment revenue", "FY2024", "Services"): (
|
||||
297.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-note-geography.txt", "Americas revenue", "FY2025", "Americas"): (
|
||||
764.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-note-geography.txt", "EMEA revenue", "FY2025", "EMEA"): (
|
||||
343.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-note-geography.txt", "APAC revenue", "FY2025", "APAC"): (
|
||||
177.0,
|
||||
"USD millions",
|
||||
),
|
||||
(
|
||||
"data/10-k-note-balance-sheet.txt",
|
||||
"Cash and cash equivalents",
|
||||
"2025-12-31",
|
||||
None,
|
||||
): (422.0, "USD millions"),
|
||||
(
|
||||
"data/10-k-note-balance-sheet.txt",
|
||||
"Cash and cash equivalents",
|
||||
"2024-12-31",
|
||||
None,
|
||||
): (351.0, "USD millions"),
|
||||
("data/10-k-note-balance-sheet.txt", "Deferred revenue", "2025-12-31", None): (
|
||||
402.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-note-balance-sheet.txt", "Deferred revenue", "2024-12-31", None): (
|
||||
337.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-operations.pdf", "Net revenue", "FY2025", None): (
|
||||
1284.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-operations.pdf", "Net revenue", "FY2024", None): (
|
||||
1008.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-operations.pdf", "Gross profit", "FY2025", None): (
|
||||
917.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-operations.pdf", "Gross profit", "FY2024", None): (
|
||||
687.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-operations.pdf", "Operating income", "FY2025", None): (
|
||||
186.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-operations.pdf", "Operating income", "FY2024", None): (
|
||||
118.0,
|
||||
"USD millions",
|
||||
),
|
||||
(
|
||||
"data/10-k-balance-sheets.pdf",
|
||||
"Cash and cash equivalents",
|
||||
"2025-12-31",
|
||||
None,
|
||||
): (422.0, "USD millions"),
|
||||
(
|
||||
"data/10-k-balance-sheets.pdf",
|
||||
"Cash and cash equivalents",
|
||||
"2024-12-31",
|
||||
None,
|
||||
): (351.0, "USD millions"),
|
||||
("data/10-k-balance-sheets.pdf", "Accounts receivable", "2025-12-31", None): (
|
||||
211.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-balance-sheets.pdf", "Accounts receivable", "2024-12-31", None): (
|
||||
187.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-balance-sheets.pdf", "Deferred revenue", "2025-12-31", None): (
|
||||
402.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-balance-sheets.pdf", "Deferred revenue", "2024-12-31", None): (
|
||||
337.0,
|
||||
"USD millions",
|
||||
),
|
||||
(
|
||||
"data/10-k-statements-of-cash-flows.pdf",
|
||||
"Net cash provided by operating activities",
|
||||
"FY2025",
|
||||
None,
|
||||
): (248.0, "USD millions"),
|
||||
(
|
||||
"data/10-k-statements-of-cash-flows.pdf",
|
||||
"Net cash provided by operating activities",
|
||||
"FY2024",
|
||||
None,
|
||||
): (192.0, "USD millions"),
|
||||
("data/10-k-statements-of-cash-flows.pdf", "Capital expenditures", "FY2025", None): (
|
||||
86.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-cash-flows.pdf", "Capital expenditures", "FY2024", None): (
|
||||
73.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-cash-flows.pdf", "Free cash flow", "FY2025", None): (
|
||||
162.0,
|
||||
"USD millions",
|
||||
),
|
||||
("data/10-k-statements-of-cash-flows.pdf", "Free cash flow", "FY2024", None): (
|
||||
119.0,
|
||||
"USD millions",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvalSummary:
|
||||
row_count: int
|
||||
|
||||
|
||||
def load_metrics(artifact_path: Path) -> FinancialMetricBatch:
|
||||
if artifact_path.suffix == ".jsonl":
|
||||
metrics = [
|
||||
FinancialMetric.model_validate_json(line)
|
||||
for line in artifact_path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
return FinancialMetricBatch(metrics=metrics)
|
||||
|
||||
if artifact_path.suffix == ".csv":
|
||||
with artifact_path.open(encoding="utf-8", newline="") as input_file:
|
||||
reader = csv.DictReader(input_file)
|
||||
metrics = []
|
||||
for row in reader:
|
||||
row["segment"] = row["segment"] or None
|
||||
row["value"] = float(row["value"])
|
||||
metrics.append(FinancialMetric.model_validate(row))
|
||||
return FinancialMetricBatch(metrics=metrics)
|
||||
|
||||
raise ValueError(f"Unsupported artifact type: {artifact_path}")
|
||||
|
||||
|
||||
def validate_outputs(metrics: FinancialMetricBatch) -> EvalSummary:
|
||||
rows = metrics.metrics
|
||||
duplicate_keys: list[MetricKey] = []
|
||||
seen_keys: set[MetricKey] = set()
|
||||
rows_by_key: dict[MetricKey, FinancialMetric] = {
|
||||
(
|
||||
row.source_file.strip(),
|
||||
row.metric_name.strip(),
|
||||
row.fiscal_period,
|
||||
row.segment.strip() if row.segment else None,
|
||||
): row
|
||||
for row in rows
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
row_key = (
|
||||
row.source_file.strip(),
|
||||
row.metric_name.strip(),
|
||||
row.fiscal_period,
|
||||
row.segment.strip() if row.segment else None,
|
||||
)
|
||||
if row_key in seen_keys:
|
||||
duplicate_keys.append(row_key)
|
||||
seen_keys.add(row_key)
|
||||
|
||||
if duplicate_keys:
|
||||
raise AssertionError(f"Duplicate metric rows found: {sorted(set(duplicate_keys))}.")
|
||||
|
||||
if len(rows) != len(EXPECTED_ROWS):
|
||||
raise AssertionError(
|
||||
f"Expected exactly {len(EXPECTED_ROWS)} metric rows, found {len(rows)}."
|
||||
)
|
||||
|
||||
for source_file, expected_section in EXPECTED_SOURCE_METADATA.items():
|
||||
source_rows = [row for row in rows if row.source_file.strip() == source_file]
|
||||
if not source_rows:
|
||||
raise AssertionError(f"Missing rows from {source_file}.")
|
||||
bad_sections = {
|
||||
row.filing_section for row in source_rows if row.filing_section != expected_section
|
||||
}
|
||||
if bad_sections:
|
||||
raise AssertionError(
|
||||
f"{source_file} filing_section mismatch. Expected {expected_section}, found {bad_sections}."
|
||||
)
|
||||
|
||||
missing_rows = [
|
||||
key
|
||||
for key, (expected_value, expected_unit) in EXPECTED_ROWS.items()
|
||||
if key not in rows_by_key
|
||||
or rows_by_key[key].value != expected_value
|
||||
or rows_by_key[key].unit != expected_unit
|
||||
]
|
||||
if missing_rows:
|
||||
observed = sorted(rows_by_key)
|
||||
raise AssertionError(
|
||||
f"Missing or mismatched expected metric rows: {missing_rows}. Observed keys: {observed}."
|
||||
)
|
||||
|
||||
unexpected_rows = sorted(set(rows_by_key) - set(EXPECTED_ROWS))
|
||||
if unexpected_rows:
|
||||
raise AssertionError(f"Unexpected metric rows found: {unexpected_rows}.")
|
||||
|
||||
return EvalSummary(row_count=len(rows))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--artifact-path",
|
||||
default=str(Path(__file__).resolve().parent / "output" / "financial_metrics.jsonl"),
|
||||
help="Path to the generated JSONL or CSV artifact.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
summary = validate_outputs(load_metrics(Path(args.artifact_path)))
|
||||
print(f"Eval checks passed for {summary.row_count} metric row(s).")
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Extract structured financial metrics from a synthetic 10-K dataroom and write a
|
||||
JSONL or CSV artifact.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
from openai.types.shared.reasoning import Reasoning
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agents import ModelSettings, Runner, RunResultStreaming, TResponseInputItem
|
||||
from agents.run import RunConfig
|
||||
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
|
||||
from agents.sandbox.capabilities import Shell
|
||||
from agents.sandbox.entries import File, LocalDir
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[4]))
|
||||
|
||||
if TYPE_CHECKING or __package__:
|
||||
from .schemas import FinancialMetric, FinancialMetricBatch
|
||||
else:
|
||||
from schemas import FinancialMetric, FinancialMetricBatch
|
||||
|
||||
from examples.sandbox.tutorials.misc import (
|
||||
DEFAULT_SANDBOX_IMAGE,
|
||||
console,
|
||||
create_sandbox_client_and_session,
|
||||
load_env_defaults,
|
||||
print_event,
|
||||
run_interactive_loop,
|
||||
)
|
||||
|
||||
DEMO_DIR = Path(__file__).resolve().parent
|
||||
DATAROOM_DATA_DIR = DEMO_DIR.parent / "data" / "dataroom"
|
||||
DEFAULT_QUESTION = (
|
||||
"Extract revenue, gross margin, operating income, cash flow, balance-sheet, segment, "
|
||||
"and geography metrics from the 10-K packet into one row per metric-period-source. "
|
||||
"For each table, include every explicit line item in the source, even when it is "
|
||||
"similar to a line item in another source."
|
||||
)
|
||||
AGENTS_MD = dedent(
|
||||
"""\
|
||||
# AGENTS.md
|
||||
|
||||
Extract structured financial metrics from the synthetic 10-K packet under `data/`.
|
||||
|
||||
## Output (one row per metric-value occurrence)
|
||||
|
||||
Required fields: `source_file`, `filing_section`, `metric_name`, `fiscal_period`, `value`,
|
||||
`unit` (`USD millions` or `percent`).
|
||||
Optional field: `segment` (segment/geography if explicitly stated, else null).
|
||||
|
||||
## Rules
|
||||
|
||||
- Review all `.txt` and `.pdf` under `data/` (these PDFs contain searchable text).
|
||||
- Use shell tools (`rg`, `sed`) for discovery/inspection; do not run Python from the sandbox shell.
|
||||
- Do not read `data/setup.py`.
|
||||
- Emit a separate row for each metric-period pair in each source file (do not dedupe across files).
|
||||
- For tables, include every explicit table line item in that source. For example, the
|
||||
statements-of-operations PDF has separate Net revenue, Gross profit, and Operating income rows.
|
||||
- Only extract explicit source line items / table rows. Do not invent rollups or “cleaned up” metrics.
|
||||
- Do not treat Gross profit and Gross margin as duplicates; they are distinct source metrics.
|
||||
- Preserve labels as written (e.g., `Revenue` vs `Net revenue`).
|
||||
|
||||
## Completeness checklist
|
||||
|
||||
Before final output, verify the batch has exactly 41 rows from these source-level line items:
|
||||
|
||||
- `data/10-k-mdna-overview.txt`: Revenue, Gross margin, and Operating income for FY2025 and FY2024.
|
||||
- `data/10-k-mdna-liquidity.txt`: Net cash provided by operating activities, Capital expenditures,
|
||||
and Free cash flow for FY2025 and FY2024.
|
||||
- `data/10-k-note-segments.txt`: Platform segment revenue and Services segment revenue for FY2025
|
||||
and FY2024, with the matching segment names.
|
||||
- `data/10-k-note-geography.txt`: Americas revenue, EMEA revenue, and APAC revenue for FY2025, with
|
||||
the matching geography names as segments.
|
||||
- `data/10-k-note-balance-sheet.txt`: Cash and cash equivalents and Deferred revenue for 2025-12-31
|
||||
and 2024-12-31.
|
||||
- `data/10-k-statements-of-operations.pdf`: Net revenue, Gross profit, and Operating income for
|
||||
FY2025 and FY2024.
|
||||
- `data/10-k-balance-sheets.pdf`: Cash and cash equivalents, Accounts receivable, and Deferred revenue
|
||||
for 2025-12-31 and 2024-12-31.
|
||||
- `data/10-k-statements-of-cash-flows.pdf`: Net cash provided by operating activities, Capital
|
||||
expenditures, and Free cash flow for FY2025 and FY2024.
|
||||
|
||||
Return the structured rows directly in your final output.
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def print_streamed_result(result: RunResultStreaming) -> BaseModel:
|
||||
async for event in result.stream_events():
|
||||
print_event(event)
|
||||
if result.final_output is None:
|
||||
raise RuntimeError("10-K Metric Extractor returned no structured metric output.")
|
||||
print_event(str(result.final_output).strip())
|
||||
return cast(BaseModel, result.final_output)
|
||||
|
||||
|
||||
def write_jsonl(path: Path, metrics: Sequence[BaseModel]) -> None:
|
||||
path.write_text(
|
||||
"\n".join(metric.model_dump_json() for metric in metrics) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def write_csv(path: Path, metrics: list[FinancialMetric]) -> None:
|
||||
with path.open("w", encoding="utf-8", newline="") as output_file:
|
||||
writer = csv.DictWriter(
|
||||
output_file,
|
||||
fieldnames=[
|
||||
"source_file",
|
||||
"filing_section",
|
||||
"metric_name",
|
||||
"fiscal_period",
|
||||
"value",
|
||||
"unit",
|
||||
"segment",
|
||||
],
|
||||
)
|
||||
writer.writeheader()
|
||||
for metric in metrics:
|
||||
writer.writerow(json.loads(metric.model_dump_json()))
|
||||
|
||||
|
||||
def write_final_artifact(
|
||||
output_dir: Path,
|
||||
output_format: Literal["jsonl", "csv"],
|
||||
metrics: list[FinancialMetric],
|
||||
) -> Path:
|
||||
output_path = output_dir / f"financial_metrics.{output_format}"
|
||||
if output_format == "jsonl":
|
||||
write_jsonl(output_path, metrics)
|
||||
else:
|
||||
write_csv(output_path, metrics)
|
||||
return output_path
|
||||
|
||||
|
||||
async def main(
|
||||
model: str,
|
||||
question: str,
|
||||
output_format: Literal["jsonl", "csv"],
|
||||
use_docker: bool,
|
||||
image: str,
|
||||
no_interactive: bool,
|
||||
) -> None:
|
||||
if not (DATAROOM_DATA_DIR / "10-k-mdna-overview.txt").exists():
|
||||
raise SystemExit(
|
||||
"Run `uv run python examples/sandbox/tutorials/data/dataroom/setup.py` "
|
||||
"before starting this demo."
|
||||
)
|
||||
|
||||
manifest = Manifest(
|
||||
entries={
|
||||
"AGENTS.md": File(content=AGENTS_MD.encode("utf-8")),
|
||||
"data": LocalDir(src=DATAROOM_DATA_DIR),
|
||||
}
|
||||
)
|
||||
agent = SandboxAgent(
|
||||
name="10-K Metric Extractor",
|
||||
model=model,
|
||||
instructions=AGENTS_MD,
|
||||
capabilities=[Shell()],
|
||||
model_settings=ModelSettings(
|
||||
reasoning=Reasoning(effort="high"),
|
||||
tool_choice="required",
|
||||
),
|
||||
output_type=FinancialMetricBatch,
|
||||
)
|
||||
|
||||
client, sandbox = await create_sandbox_client_and_session(
|
||||
manifest=manifest,
|
||||
use_docker=use_docker,
|
||||
image=image,
|
||||
)
|
||||
try:
|
||||
async with sandbox:
|
||||
extracted_metrics: FinancialMetricBatch | None = None
|
||||
|
||||
async def run_turn(
|
||||
conversation: list[TResponseInputItem],
|
||||
) -> list[TResponseInputItem]:
|
||||
nonlocal extracted_metrics
|
||||
|
||||
result = Runner.run_streamed(
|
||||
agent,
|
||||
conversation,
|
||||
max_turns=25,
|
||||
run_config=RunConfig(
|
||||
sandbox=SandboxRunConfig(session=sandbox),
|
||||
tracing_disabled=True,
|
||||
workflow_name="Dataroom extraction example",
|
||||
),
|
||||
)
|
||||
extracted_metrics = cast(FinancialMetricBatch, await print_streamed_result(result))
|
||||
return result.to_input_list()
|
||||
|
||||
conversation: list[TResponseInputItem] = [{"role": "user", "content": question}]
|
||||
conversation = await run_turn(conversation)
|
||||
await run_interactive_loop(
|
||||
conversation=conversation,
|
||||
no_interactive=no_interactive,
|
||||
run_turn=run_turn,
|
||||
)
|
||||
finally:
|
||||
await client.delete(sandbox)
|
||||
|
||||
if extracted_metrics is None:
|
||||
raise RuntimeError("10-K Metric Extractor returned no structured metric output.")
|
||||
|
||||
output_dir = DEMO_DIR / "output"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
artifact_path = write_final_artifact(output_dir, output_format, extracted_metrics.metrics)
|
||||
console.print(
|
||||
f"[green]Wrote {len(extracted_metrics.metrics)} metric row(s) to {artifact_path}[/green]"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_env_defaults(DEMO_DIR / ".env")
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="gpt-5.4-mini",
|
||||
help="Model name to use.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--question",
|
||||
default=DEFAULT_QUESTION,
|
||||
help="Prompt to send to the agent.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-format",
|
||||
choices=("jsonl", "csv"),
|
||||
default="csv",
|
||||
help="Artifact format.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--docker",
|
||||
action="store_true",
|
||||
help="Run this example in Docker instead of Unix-local.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--image",
|
||||
default=DEFAULT_SANDBOX_IMAGE,
|
||||
help="Docker image to use when --docker is set.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-interactive",
|
||||
action="store_true",
|
||||
help="Run the scripted turn and skip follow-up terminal input.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(
|
||||
main(
|
||||
args.model,
|
||||
args.question,
|
||||
args.output_format,
|
||||
args.docker,
|
||||
args.image,
|
||||
args.no_interactive,
|
||||
)
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user