Agent Sandbox
Agent Sandbox RL
Generic, multi-cluster batch orchestration for running SWE-bench-style RL and evaluation workloads on Agent Sandbox.
It builds on k8s-agent-sandbox
and turns the full run lifecycle into a small, framework-agnostic API:
load images → configure cluster(s) → compute replicas → preflight → warm pools → claim a sandbox per task (hostname/endpoint per sandbox) → run → tear down.
It plugs into any RL stack (R2E-Gym, tunix, TorchRL, SkyRL): the integration
point is a SandboxHandle (stable hostname, endpoint, router-free exec). Sync
and async; low-level primitives and a managed runner; one cluster
or many. Targets the v1beta1 (“beta”) Agent Sandbox API.
- Design:
docs/design.md - Architecture & lifecycle:
docs/architecture.md - RL-framework integration:
examples/rl_integration.md
Why
k8s-agent-sandbox is single-sandbox / single-cluster and has no
SandboxTemplate/WarmPool CRUD, sizing, preflight, pre-pull, or batching — every
consumer re-implements those. agent-sandbox-rl provides them once, generically,
across clusters.
Setup
1. Prerequisites
| Requirement | Notes |
|---|---|
| Python ≥ 3.11 | The package targets 3.11+. |
| A Kubernetes cluster | With the Agent Sandbox controller + v1beta1 extensions installed (next step). GKE, kind, or any conformant cluster. |
kubectl + a kube context |
agent-sandbox-rl reads your kubeconfig; each ClusterConfig selects a context by name (context=), or uses the ambient one. |
gke-gcloud-auth-plugin |
GKE only — must be on PATH (gcloud components install gke-gcloud-auth-plugin). |
| Worker node capacity | Pods land on the nodes your TemplateSpec selects (node_selector) and, if set, need a matching runtime_class (e.g. gvisor). |
2. Install the Agent Sandbox controller (cluster side)
agent-sandbox-rl orchestrates CRDs; it does not install them. The cluster must
already serve the v1beta1 SandboxTemplate / SandboxWarmPool /
SandboxClaim / Sandbox resources. Apply the controller + extensions from a
release, then verify:
kubectl get crd | grep agents.x-k8s.io # expect the 4 CRDs
kubectl get pods -n agent-sandbox-system # controller Running
fleet.preflight() checks all of this for you and raises PreflightError with a
precise message if something is missing.
3. Install the Python packages (client side)
Both the SDK and this package are installed editable from the repo. Run from the repo root:
# core: SDK + this package
pip install -e clients/python/agentic-sandbox-client \
-e examples/agent-sandbox-rl
# with the SWE-bench dataset loader (recommended for SWE-bench runs)
pip install -e clients/python/agentic-sandbox-client \
-e 'examples/agent-sandbox-rl[swebench]'
Dependencies & extras
Core deps (installed automatically): k8s-agent-sandbox (the SDK — reused, not
forked), kubernetes, and pydantic>=2. That’s it — the always-on RunReport
is dependency-free, and merely importing the package registers no Prometheus
collectors (Prometheus is the optional metrics extra below).
| Extra | Pulls in | Use it for |
|---|---|---|
swebench |
datasets (Hugging Face) |
SweBenchSource — loading SWE-bench task lists. |
async |
k8s-agent-sandbox[async], kubernetes_asyncio |
AsyncSandboxFleet on an asyncio loop. |
metrics |
prometheus-client |
Export asrl_* Prometheus series (enable_metrics=True). Without it, RunReport still works. |
tracing |
opentelemetry-api / -sdk / -exporter-otlp (~=1.39) |
OpenTelemetry span export (enable_tracing=True). No-op when absent. |
test |
pytest, pytest-asyncio, pytest-xdist |
Running the mocked unit tests. |
Combine extras with commas, e.g. …/agent-sandbox-rl[swebench,async,metrics].
The R2E-Gym adapter (adapters.r2egym) needs R2E-Gym, which isn’t on PyPI —
install it from its checkout (pip install -e path/to/R2E-Gym); the adapter
raises a clear error if it’s missing. (No r2egym extra for that reason.)
4. Verify
# unit tests (mocked, no cluster needed)
pytest examples/agent-sandbox-rl
# import + reach your cluster
python -c "import agent_sandbox_rl as a; print('agent-sandbox-rl', a.__version__)"
python -c "from agent_sandbox_rl import SandboxFleet, FleetConfig, ClusterConfig; \
SandboxFleet(FleetConfig(clusters=[ClusterConfig(name='c', namespace='default')])).preflight()"
A clean preflight() (no PreflightError) means you’re ready for the Quickstart.
Quickstart
Managed runner (simplest)
from agent_sandbox_rl import SandboxFleet, FleetConfig, ClusterConfig, SweBenchSource, swebench_probe
fleet = SandboxFleet(FleetConfig(
clusters=[ClusterConfig(name="rl", namespace="rl-tunix-swebench")],
max_concurrent=8, max_warmpool_size=32, placement="image-affinity"))
fleet.load_tasks(SweBenchSource(limit=8))
# strategy: none | naive | sliding | pipelined (concurrency defaults to max_concurrent)
results = fleet.run(swebench_probe, strategy="sliding", concurrency=8)
Primitives (RL loop owns the schedule)
fleet.load_tasks([{"id": "t1", "image": "busybox:1.36"}])
fleet.setup() # preflight → plan → warm pools
for task in fleet.tasks:
h = fleet.acquire(task) # claim a pre-warmed sandbox
try:
print(h.hostname, h.endpoint())
print(h.exec(["sh", "-c", "echo hi $(hostname)"])) # router-free
finally:
fleet.release(h)
fleet.teardown()
# or: `with fleet: ...` (setup on enter, teardown on exit)
Async
from agent_sandbox_rl import AsyncSandboxFleet
fleet = AsyncSandboxFleet(cfg); fleet.load_tasks(src)
results = await fleet.run(async_or_sync_process_fn, strategy="naive", concurrency=64)
# or: async with fleet: h = await fleet.acquire(task); ...
CLI example
cd examples
WARMPOOL_STRATEGY=sliding TASKS_LIMIT=4 MAX_CONCURRENT=4 NAMESPACE=rl-tunix-swebench \
NODE_SELECTOR_KEY=cloud.google.com/gke-nodepool NODE_SELECTOR_VAL=e2-pool \
python run_swebench_fleet.py
Concepts
| Concept | What it is |
|---|---|
| Task | id + container image + opaque metadata. Generic unit of work. |
| TaskSource | Produces tasks: ListSource, JsonlSource, SweBenchSource (HF). |
| FleetConfig | Clusters + orchestration knobs (concurrency, sizing, placement, template). |
| ClusterConfig | One target cluster (context/kubeconfig, namespace, node selector, runtime class, pull secret, weight, capacity). |
| SandboxFleet / AsyncSandboxFleet | The orchestrator (sync / async). |
| SandboxHandle | A claimed sandbox: hostname, pod_name, pod_ip, endpoint(port), exec(cmd), release(). |
| Placement | Which cluster serves an image: round-robin, least-loaded, capacity-weighted, image-affinity. |
| Strategy | When pools exist: none, naive, sliding, pipelined. |
| Recycling | Reuse one sandbox across same-image tasks (reset between): run(recycle=True) — orthogonal to strategy — backed by reuse_git_restore_sandbox + GitRestoreReset (claims scale ÷ tasks-per-image). |
| Adapters | Framework glue: adapters.swebench (dataset → tasks), adapters.r2egym (make_fleet_repo_env binds a warm pod into R2E-Gym/tunix RepoEnv). |
Warm-pool strategies
| Strategy | Behavior | Footprint | Best for |
|---|---|---|---|
naive |
Pre-warm every image up front; process all (parallel); tear down. | Highest (all pools at once). | Small/medium image sets; RL (with warm_per_task). |
sliding |
Keep only a window of image pools warm, rolling forward. | Bounded (~window); window auto-sizes to max_concurrent. |
Large image sets on limited disk. |
pipelined |
Like sliding, but prefetch window N+1’s pools while window N’s tasks run, so image pull overlaps execution. |
Bounded (≤ 2 windows; the window is halved so peak ≈ max_concurrent). |
Pull-bound eval sweeps (many distinct images, 1 task each). |
none |
One size-1 pool per image on demand, torn down after. | Lowest (cold-start per image). | Tiny runs / debugging. |
How to choose — two questions: does the warm set fit on disk, and is pull the bottleneck?
- Does the whole warm set fit on the node/pool’s disk?
naivewarms every pool at once, so all resident images must fit simultaneously. If they fit,naiveis the simplest and reaches steady state fastest. If they don’t fit (large image sets — the common case at hundreds+ of multi-GB images), you must bound what’s resident at any moment → useslidingorpipelined, whose window auto-sizes to the disk budget (avg_image_gb/node_ephemeral_gb/cluster_nodes; see Disk-aware window). This is the primary reason to reach for a windowed strategy: the images don’t fit storage. - Is image pull the bottleneck? For a 1:1 eval sweep (hundreds of distinct
images, one task each) pull dominates wall time →
pipelinedhides each window’s pull behind the previous window’s execution. So usepipelinedwhen either the set doesn’t fit disk or pull dominates (usually both, for eval). - RL rollouts (1:G) — each image is claimed by G rollouts, so pools are deep. Use
naiveorslidingwithwarm_per_task(+colocate_replicas), notpipelined: deep per-image replicas shrink the pipelined window and serialize problems (measured wall 55 s → 97 s). See Eval vs RL.
In short: naive when everything fits and you want simplicity; pipelined/sliding
when the image set is too big for disk (windowed = fits by construction), with
pipelined adding pull/exec overlap for pull-bound eval; naive/sliding + warm_per_task
for RL.
For repeated passes over the same dataset (RL epochs), use epochs=N to keep
pools resident between passes (re-pulls then hit the node layer cache — see
Strategies & tuning), or keep_warm=True to drive your
own loop:
# 3 epochs over all tasks, pools reused between them, torn down once at the end
results = fleet.run(process_fn, strategy="pipelined", epochs=3) # -> list[list]
# or keep pools warm for your own training loop, then clean up explicitly
fleet.run(process_fn, strategy="naive", keep_warm=True)
# ... reuse warm pools across your own iterations ...
fleet.teardown()
Replica sizing
Pool depth is the image’s share of the concurrency budget, not its task count:
replicas_image = clamp(round(MAX_CONCURRENT × tasks_image / tasks_total),
1, min(tasks_image, MAX_WARMPOOL_SIZE))
MAX_CONCURRENT is the one knob that both sizes pools and parallelizes
claim+exec. This is the core cost win — it avoids warming N pods for N tasks
while keeping sub-second claims. python -m agent_sandbox_rl.sizing prints the
old-vs-new footprints; for a skewed 100-task / 8-image batch (MAX_WARMPOOL_SIZE=32):
MAX_CONCURRENT |
baseline min(count, cap), all warm |
concurrency-aware footprint | sliding window |
|---|---|---|---|
| 1 | 92 pods | 8 pods | 1 |
| 8 | 92 pods | 11 pods | 5 |
| 32 | 92 pods | 32 pods | 8 |
| 256 | 92 pods | 92 pods | 8 |
The naive (warm-everything) baseline holds 92 pods regardless; sizing pools to the
concurrency budget cuts that to 8–32 for the same throughput, and sliding bounds
it further to a window.
Instant-claim mode (RL)
The default sizing optimizes for cost (fewest warm pods for a given throughput). For RL rollouts, claim latency (time-to-sandbox) often matters more — and RL hits the same image repeatedly, so the 2nd concurrent rollout on an image shouldn’t queue behind the 1st. Two opt-in levers (both default off, so existing behavior is unchanged):
fleet = SandboxFleet(FleetConfig(
clusters=[ClusterConfig(name="rl", namespace="rl")],
max_concurrent=50, max_warmpool_size=16,
warm_per_task=True, # 1 warm replica per task
template=TemplateSpec(colocate_replicas=True))) # pack a pool's replicas on one node
fleet.run(process_fn, strategy="naive") # warm everything, full depth, packed
warm_per_tasksizes each pool tomin(tasks_image, max_warmpool_size)— one ready replica per task — so every task claims immediately. Raisemax_warmpool_sizefor images with more tasks than the cap (it warns and clamps otherwise).colocate_replicasadds a soft pod-affinity so a pool’s replicas prefer the same node: only the first replica pulls the image, the rest start from the node’s containerd layer cache (pairs with the defaultimage_pull_policy: IfNotPresent). Soft, so it spills to other nodes instead of dead-locking when a node fills. Size the node forreplicas × cpu_request(e.g. 50 × 250m ≈ 13 vCPU).- Deep/large warms stage automatically —
warm_per_taskacross many images can ask the controller to create tens of thousands of replicas at once.start_warmpoolsfills in waves of ≤warm_create_budget(default 1000) creates, waiting for each wave to be Ready before the next. Setwarm_create_budget=0to warm all at once.Version note: staging bounds the controller’s create burst on any version, but on releases ≤ v0.5.3 it only mitigates (not prevents) the warm-pool over-creation churn bug (#1215) — there, also set the controller flag
--sandbox-warm-pool-concurrent-workerslow (≤10) (leave sandbox/claim workers high). On v0.5.4+ (#1266) the bug is fixed and100is a good default (see the controller-flags table below). For deep warms on any version, recycle one sandbox per problem (see Sandbox recycling below) remains the lower-footprint alternative to deep-warming.
When does this help? Only when an image carries more than one task. A 1:1 eval sweep (one image per task — see below) gets
min(1, …) = 1replica, sowarm_per_taskis a no-op there. The payoff is RL rollouts, where the same problem image is claimed by G rollouts at once.It improves claim latency (time-to-sandbox), not batch wall — wall is bounded by
max_concurrent, which the default sizing already saturates. The win is the tail: every rollout gets its own ready sandbox instead of queueing, which in a synchronous RL step (you wait for the slowest of G rollouts) directly cuts straggler delay. Measured (10 problems × 8 rollouts, 15 s each):naiveclaim tail 9 s → 6 s, wall ≈ flat.Use
naiveorslidingwithwarm_per_task— notpipelined. With deep per-image replicas the pipelined window shrinks to keep its footprint bounded, which serializes problems and underfillsmax_concurrentonce rollouts do real work (measured: pipelined wall 55 s → 97 s).pipelinedis for the 1:1 eval case.
Sandbox recycling (reset-and-reuse) — experimental
warm_per_task gives each rollout its own fresh sandbox; recycling instead keeps
one claimed sandbox and resets it between rollouts on the same image, so claims
scale with problems, not tasks (÷ G). At the RL shape (G rollouts/problem) that is
G× fewer claims, controller reconciles, and API-server writes.
Turn it on with the recycle=True flag on fleet.run(...) — an orthogonal
modifier, not a strategy value: the chosen strategy still governs warming, recycle
only swaps the task→sandbox binding to reset-and-reuse. It’s off by default (a no-op for
1:1 eval; it only helps multi-task-per-image shapes, and not every RL/eval scenario
resets cleanly).
from agent_sandbox_rl import determinism_canary
# reuse one sandbox per image; reset between same-image tasks, quarantine if dirty.
# strategy warms the pools; run() manages setup / RunReport / teardown.
results = fleet.run(process_fn, strategy="naive", concurrency=40,
recycle=True, max_reuses=32, reset_timeout=5.0)
# async twin: await afleet.run(process_fn, strategy="naive", recycle=True, …)
# low-level (full control outside run()): reuse_git_restore_sandbox(fleet, tasks, fn, conc)
The reset (GitRestoreReset) restores /testbed to a pristine git tag, sweeps
processes + /tmp, then verifies the repo is back at the pristine SHA and clean;
any drift quarantines the sandbox (release + fresh claim) rather than risk
contaminating the next rollout — in RL a polluted sandbox silently biases rewards,
worse than a crash. By default the reset is git-only (fast); the expensive
site-packages (pip freeze) and git-config/hooks tripwires are opt-in
(GitRestoreReset(check_env=True, check_config=True)) since env drift is rare and
already bounded by max_reuses + the canary. The env-restore and overlay/checkpoint
tiers are deferred; drift there escalates to a fresh claim.
Two things make this scale (both on by default):
- Persistent exec session (
use_session=True) — one held-openbashstream per sandbox, so task + reset pipe over a single websocket instead of one apiserverexecconnect per command (exec cost O(sandboxes), not O(tasks)). - Safe on mixed image sets — a non-git
/testbed(no pristine anchor) can’t be git-restored, so those images transparently fall back to a fresh claim per task; anexecfailure mid-reset quarantines rather than aborting the batch.
Measured (barkland-brust, no-op, mc=100): at the RL shape (50 problems × 40 rollouts)
reuse beat fresh-claim on wall (416s vs 944s), claims (81 vs 1,987, 24.5×), and
success (100% vs 99.35%) — regular’s shallow per-image warm pool saturates under
same-image contention. At eval shape (many distinct images, 1:1) keep fresh-claim +
pipelined; reuse only cuts claims there. See plans/sandbox-recycling.md.
⚠️ Verify before trusting it for training. Run the determinism canary first — the same seeded task twice in one recycled sandbox must produce byte-identical output:
out = determinism_canary(fleet, task, process_fn) assert out["identical"] and out["reset_clean"]Recycling is experimental; the design, hardening findings, and known limits are in
plans/sandbox-recycling.md(notes repo).
Eval vs RL — recommended recipes
The two workloads pull in opposite directions: eval is pull-bound (many distinct
images, one task each), RL is claim-bound per problem (one image, many rollouts).
(Eval uses pipelined because a large distinct-image set rarely fits disk and pull
dominates; if your eval set is small enough to fit resident, plain naive also works
and is simpler — see How to choose.)
| Job | Image : task | Strategy | Sizing levers | Why |
|---|---|---|---|---|
| Eval (SWE-bench sweep) | 1 : 1 | pipelined |
default (concurrency-aware) + epochs/keep_warm + IfNotPresent + in-region mirror |
Pull-bound. Overlap pulls and reuse the node cache. warm_per_task and colocate_replicas are both no-ops at 1 task/image (one replica → nothing to co-locate). |
| RL rollouts (GRPO / deepswe) | 1 : G | naive or sliding |
warm_per_task=True (+ max_warmpool_size ≥ G) + colocate_replicas=True |
Every rollout claims instantly → lowest straggler tail. Avoid pipelined (window shrinks, serializes problems). |
# Eval: 1:1 sweep, pull-bound — overlap pulls, reuse cache
fleet = SandboxFleet(FleetConfig(clusters=[...], max_concurrent=40))
fleet.run(eval_fn, strategy="pipelined", epochs=1)
# RL: G rollouts/problem — instant claims, no window shrinkage
fleet = SandboxFleet(FleetConfig(
clusters=[...], max_concurrent=40, max_warmpool_size=8, # >= rollouts/problem
warm_per_task=True, template=TemplateSpec(colocate_replicas=True)))
fleet.run(rollout_fn, strategy="naive", keep_warm=True) # reuse across steps
The load test reproduces both: --tasks-per-image 1 (eval) vs
--tasks-per-image G --warm-per-task --colocate (RL). Add reuse to --strategies
(e.g. --strategies naive,pipelined,reuse) to compare recycling against the warm-pool
strategies in the same run (--max-reuses/--reset-timeout/--check-env tune it).
Multi-cluster
Pass several ClusterConfigs (different context/kubeconfig) + a placement;
the fleet builds a per-context client for each, distributes pools/claims, and each
SandboxHandle carries its owning cluster. Cross-cluster reachability is the
caller’s concern (see the integration guide).
Multiple clusters and multiple node pools
Each ClusterConfig targets one placement domain via its node_selector, so plan
a deployment as one ClusterConfig per (cluster × node pool) and let a
placement strategy spread work across them. For pools within the same cluster,
give each config the same context but a distinct node_selector — and a
distinct namespace: SandboxTemplate/WarmPool names are keyed by image only
(r2e-img-<hash>), so two same-namespace configs warming the same image would
collide; separate namespaces keep them isolated. Prefer capacity-weighted or
least-loaded placement when pools are heterogeneous (e.g. a 110-pod e2 pool
alongside a 256-pod n2 pool) so the larger pool gets a proportional share, and set
each config’s capacity/weight accordingly. (To instead let the scheduler place
across pools freely, use a single config with no node_selector and a node
affinity in TemplateSpec.extra_pod_spec — simpler, but you lose control of the
split across pools with different pod caps.)
Configuration reference
FleetConfig: clusters, placement, max_concurrent (1), max_warmpool_size
(8), warm_per_task (False — one warm replica per task for instant claims),
window_size (None=auto), ready_timeout (900), warm_create_budget (1000 — stage
the warm fill in waves of ≤ N sandbox creates in flight to bound the controller’s
create burst; on controllers ≤ v0.5.3 also pair with a low
--sandbox-warm-pool-concurrent-workers to dodge #1215; 0 = warm all at once), template
(TemplateSpec), template_name_prefix (r2e-img-), labels. Disk-aware sizing (optional):
avg_image_gb, node_ephemeral_gb, disk_headroom (0.25), cluster_nodes
(None) — when set, the auto window for sliding/pipelined is capped so resident
images fit disk; cluster_nodes makes that the whole pool’s disk (distinct images
spread across nodes) instead of a single node’s (None = conservative single-node
bound; the capacity planner sets it from the probed node count).
Runaway safeguards (see plans/sdk-runaway-safeguards.md): overcommit_factor
(1.5) + max_live_sandboxes (None) — the circuit breaker: if live sandboxes this
run owns exceed min(expected × factor, max_live_sandboxes), the fleet tears down and
raises FleetOvercommitError (catches accidental over-creation; factor=0 disables);
breaker_poll_s (5.0). install_teardown_hooks (True) installs atexit/SIGINT/SIGTERM
teardown on graceful exits (normal return, exceptions, SIGINT/SIGTERM) — these are
best-effort and can’t catch SIGKILL / OOM / node loss. For those abrupt cases,
every resource is labelled with fleet.run_id, and reap(run_id=…) / python -m agent_sandbox_rl.reaper is the recovery path — sweeping an orphaned run by label. plan() also emits advisory plan.warnings (never fatal)
for footprint/concurrency beyond what the control plane comfortably absorbs.
ClusterConfig: name, kubeconfig, context, in_cluster, namespace,
node_selector, runtime_class, image_pull_secret, weight, max_replicas.
TemplateSpec: resources (cpu/memory), keepalive_command (sleep infinity),
runtime_class, node_selector, image_pull_secret, image_pull_policy
(IfNotPresent — reuses the node layer cache across epochs), colocate_replicas
(False — prefer scheduling a pool’s replicas on one node for cache reuse),
extra_pod_spec.
Image rewriting (optional): redirect task images at an in-region mirror / pull-through cache without touching the source:
from agent_sandbox_rl import make_rewriter
fleet.load_tasks(source, image_rewrite=make_rewriter(
registry="us-docker.pkg.dev", project="my-proj", repo="swebench-mirror"))
# docker.io/... -> us-docker.pkg.dev/my-proj/swebench-mirror/...
# (original preserved in metadata['original_image'])
Operational features
- Preflight (
fleet.preflight()): per-cluster reachability, v1beta1 CRD versions, controller, namespace, and (if configured) runtime class + pull secret. Hard failures raisePreflightError; soft issues are warnings. - Pre-pull (
fleet.prepull()/setup(prepull=True)): a DaemonSet caches task images on every node so warm pools skip the multi-GB pull. This is where cold-start time goes —wait_pool_readydominates a cold run (the sample report shows it as ~34 s of a 48 s run), so pre-pulling (or a persistent node-level image cache) is the single biggest lever for repeated/RL runs. See docs/strategies.md §4. - Watch-based readiness:
wait_for_pool_readywatches the WarmPool and returns at thereadyReplicasevent (near-exact timing, no fixed poll grid), reconnecting and falling back to a short re-check on watch drops. - Cleanup: everything created is labeled
app=agent-sandbox-rl;teardownsweeps claims → pools → templates (defensive against stray claims).
Observability
Three layers, mirroring the k8s-agent-sandbox SDK so traces/metrics interoperate:
-
RunReport— always-on, dependency-free.fleet.run(...)records per-phase timings (preflight, plan, create_warmpool, wait_pool_ready, claim, process, release, teardown), claims, tasks ok/err, warm-replica total+peak, and anenvironmentblock (cluster context/namespace/k8s-version/nodes/node-pools/ instance-types/region, viafleet.describe_environment()).results = fleet.run(probe, strategy="naive") print(fleet.report.summary()) # benchmark table (also logged at INFO) data = fleet.report.to_dict() # JSON-friendly── Run report (strategy=naive) ── environment: default: context=(ambient) namespace=rl-tunix-swebench k8s_version=v1.35... nodes=11 node_pools=[e2-pool,...] region=us-central2 preflight 1.35s (n=1, max=1.35s) wait_pool_ready 8.44s (n=2, max=4.22s) claim 5.66s (n=4, max=1.69s) process 1.56s (n=4, max=0.40s) ... TOTAL 14.71s claims=4 tasks=4ok/0err warm_replicas total=3 peak=3examples/run_swebench_fleet.pywrites a timestamped.txt+.jsonreport toREPORT_DIRwhen that env var is set. Seeperformance_reports/README.mdfor a full breakdown of every phase and metric. (Note: per-phase totals are summed durations, so under concurrency they exceed the wall-clockTOTAL.) -
Prometheus metrics (opt-in, default on; needs the
metricsextra) —asrl_*series on the default registry. The collectors are registered lazily on the first metrics-enabled run, so importing the package has no global side effect (and withprometheus-clientnot installed, metrics are a silent no-op whileRunReportkeeps working). Series:asrl_phase_latency_seconds,asrl_task_latency_seconds,asrl_run_latency_seconds(histograms),asrl_claims_total,asrl_tasks_total(counters),asrl_warm_replicas(gauge). Labels are bounded:phase · cluster · family · strategy · status(familyis the repo family, not the per-image tag). Expose them with the built-in helper:from agent_sandbox_rl import serve_metrics server, _ = serve_metrics(port=9095) # GET /metrics ; caller owns lifetime -
OpenTelemetry spans (opt-in, default off; needs the
tracingextra) — reuse the SDK’s tracer/provider so fleetasrl.*spans nest with the SDK’screate_claim/wait_for_sandbox_readyspans in one trace.FleetConfig(..., observability=ObservabilityConfig( enable_metrics=True, enable_tracing=True))(
asyncio.to_threaddoesn’t auto-propagate OTel context — underAsyncSandboxFleet, metrics +RunReportare exact; nested SDK spans are a documented follow-up.)
Performance tuning
docs/strategies.md is the consolidated reference — every strategy, sizing mode, and caching/infra lever, with the workload each fits and the exact flag. It opens with a decision table (workload → strategy → flags) and an all-levers-at-a-glance table.
For eval (1:1 sweeps), wall-clock is dominated by image pull, not task work.
The levers — pipelined (overlap pull with execution), epochs/keep_warm +
IfNotPresent (amortize pulls across passes via the node layer cache), pre-pull
(fleet.prepull() — a DaemonSet that caches images on every node before the hot
path), an in-region Artifact Registry mirror (the image_rewrite hook), GKE Image
Streaming, and disk-aware window sizing — are all detailed there.
For RL (G rollouts per problem), the lever is instant claims —
warm_per_task + colocate_replicas with
naive/sliding, which minimize the per-rollout claim tail (the synchronous
straggler) rather than batch wall.
Capacity-aware planning (full batches)
The planner reads a node pool’s CPU + ephemeral storage + pod density and computes the
optimal preload plan for running all N tasks — strategy, max_concurrent, and
per-image replicas/window — so every image is pulled + uncompressed and the sandboxes
are warm before the task phase. It picks naive (warm everything) when the whole set
fits the pool’s disk/CPU/pods, else a disk-bounded pipelined window, and reports the
binding bottleneck (cpu / disk / pods). For RL shapes (tasks_per_image > 1) it enables
warm_per_task + colocate_replicas. Three ways to use it:
1. Importable API (agent_sandbox_rl.capacity):
from agent_sandbox_rl import (Cluster, ClusterConfig, probe_capacity,
plan_benchmark, render_plan)
core = Cluster(ClusterConfig(context="my-ctx")).core_api
cap = probe_capacity(core, "cloud.google.com/gke-nodepool=my-pool")
plan = plan_benchmark(cap, n_images=500, tasks_per_image=1, avg_image_gb=10)
print(render_plan(cap, plan)) # capacity + recommended strategy/concurrency/replicas
# -> plan.strategy, plan.max_concurrent, plan.replicas_per_image, plan.bottleneck ...
2. Interactive wizard — consults you (cluster, node pool, batch shape), prints the plan, and offers to run it. Plan-only/read-only by default:
python examples/plan_capacity.py # prompts; or pass flags + --non-interactive
3. Benchmark CLI — plan + optional timed run (preload vs task), writes a report:
PYTHONPATH=. python tests/run_full_swebench_benchmark.py \
--context <ctx> --namespace <ns> \
--node-selector cloud.google.com/gke-nodepool=<pool> \
--n-images 500 --avg-image-gb 10 # add --execute to actually run
Setting your controller to high scale
The fleet can fan out hundreds–thousands of concurrent claims, but throughput is
ultimately bounded by the Agent Sandbox controller’s reconcile concurrency, not
this package. The controller ships with conservative defaults: the Sandbox
controller reconciles at 1 worker and SandboxClaim at 50 — so a
1000-wide max_concurrent run still has its sandbox state transitions serialized
one at a time. For large eval/RL batches, raise the controller’s worker flags (on
the controller Deployment’s container args, namespace agent-sandbox-system):
| flag | default | recommended (high scale) | why |
|---|---|---|---|
--kube-api-qps |
-1 |
-1 (leave) |
-1 disables client-side rate limiting entirely — already optimal. |
--kube-api-burst |
10 |
n/a | Moot while qps=-1 (burst is only consulted when QPS > 0). Only raise if you set a positive QPS. |
--sandbox-concurrent-workers |
100 |
1000 |
Sandbox reconciles (claim binding → Ready) are the main serializer; match your peak concurrent sandboxes. |
--sandbox-claim-concurrent-workers |
50 |
1000 |
Concurrent SandboxClaim reconciles; match peak in-flight claims. |
--sandbox-warm-pool-concurrent-workers |
1 |
100 (v0.5.4+) |
On v0.5.4+ the over-creation churn bug (#1215, fixed by #1266: expectations-gated creates, terminating-aware counting) is gone — parallel warm-pool reconciles are safe. 100 is a good default (fast replenishment, bounded apiserver burst); raise toward your warm width if the warm-fill wall matters (validated clean at 500 with 500 pools). On ≤ v0.5.3 keep it low (≤10; 1 fully serializes) — there high values race a stale informer cache and over-create → delete → re-create sandboxes (observed ~17K pods for an 8K target). |
--sandbox-template-concurrent-workers |
1 |
1000 |
Parallel template reconciles. |
--sandbox-warm-pool-max-batch-size |
300 |
1000 |
Parallel pod create/delete within one warm-pool reconcile. |
Key point: --kube-api-qps/--kube-api-burst are not the lever — the
controller’s API client is already uncapped at qps=-1. The worker concurrency
flags are what unblock high-scale claims. Size them to your max_concurrent. Also
size this package’s client pool (build_api_client defaults the urllib3
connection_pool_maxsize to 1000) to match — otherwise the driver throttles before
the controller does. Version note: --sandbox-warm-pool-concurrent-workers is
release-gated (see the row above) — 100 on v0.5.4+ (#1266 fixed the #1215 churn;
validated up to 500), but ≤10 on ≤ v0.5.3. The staged warm fill
(FleetConfig.warm_create_budget, default 1000) remains useful on any version to
bound the create burst of very large/deep warms.
Example patch (v0.5.4+ values — on controllers ≤ v0.5.3 use
--sandbox-warm-pool-concurrent-workers=10 instead, per the table above):
kubectl -n agent-sandbox-system patch deploy agent-sandbox-controller --type=json -p '[
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--sandbox-concurrent-workers=1000"},
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--sandbox-claim-concurrent-workers=1000"},
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--sandbox-warm-pool-concurrent-workers=100"},
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--sandbox-template-concurrent-workers=1000"},
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--sandbox-warm-pool-max-batch-size=1000"}
]'
Troubleshooting
| Symptom | Cause / fix |
|---|---|
PreflightError: ... crd:* not found |
Agent Sandbox extensions not installed — apply the controller + extensions. |
Claims never resolve / pods Pending |
Node selector unsatisfiable, or runtimeClassName (e.g. gvisor) with no matching nodes. |
Docker Hub 429 on image pulls |
Set image_pull_secret, or mirror images to a registry / use pre-pull. |
'NoneType' object has no attribute 'decode' on parallel exec |
Handled: SandboxHandle.exec builds a fresh ApiClient per call (kubernetes stream() isn’t thread-safe across a shared client). |
Async process_fn calls handle.exec |
exec is blocking — in async code do await asyncio.to_thread(h.exec, ...), or pass a sync process_fn (run in a worker thread automatically). |
Testing
pytest examples/agent-sandbox-rl # mocked, no cluster
This suite is also wired into the repo’s unit-test runner
(dev/tools/test-unit, run by make test-unit and the unit-test presubmit),
so regressions are caught in CI — it spins up a venv, installs the in-repo SDK +
this package’s [test] extra, and runs the mocked tests.
Status
Phases 1–8 implemented and live-verified on GKE (agent-sandbox v0.5.0rc1):
config/sizing, multi-cluster, template/warm-pool CRUD, sources/placement/handles,
fleet primitives, strategies + parallel execution, preflight, pre-pull, async,
the SWE-bench adapter + example, and observability (RunReport + Prometheus +
OpenTelemetry). See docs/architecture.md
and CHANGELOG.md.