Files
2026-07-13 12:39:17 +08:00

330 lines
12 KiB
Python

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))