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
Run this if the namespace doesn’t already exist:
kubectl create namespace agent-sandbox-rl
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.)
The OpenHands adapter (adapters.openhands) likewise imports lazily and
needs the openhands-k8s-agent-sandbox
integration (pip install -e clients/integrations/openhands), which pulls the
OpenHands agent SDK — note the SDK requires Python >= 3.12. Its
agent_server_template(...) builds the TemplateSpec such pools need (agent-server
on :8000 with /health start-up and readiness probes and the session key as
OH_SESSION_API_KEYS_0; a key is required unless allow_unauthenticated=True, and the
pod always carries resource limits), either from task images that contain the server or, with
server_image=, by copying the server out of one agent-server image into every plain
repo image’s pod through an init container — no per-task image rebuild:
from agent_sandbox_rl.adapters.openhands import agent_server_template, DEFAULT_AGENT_SERVER_IMAGE
fleet = SandboxFleet(FleetConfig(
template=agent_server_template(server_image=DEFAULT_AGENT_SERVER_IMAGE,
session_key_secret=("openhands-session-key", "key"),
tolerations=[{"key": "workload", "operator": "Equal",
"value": "sandbox", "effect": "NoSchedule"}]),
warm_per_task=True, ...))
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='agent-sandbox-rl')])).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="agent-sandbox-rl")],
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=agent-sandbox-rl \
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), adapters.openhands (make_fleet_workspace/make_handle_workspace put OpenHands agent-SDK workspaces on fleet pods). |
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="agent-sandbox-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.
Streaming batches — an external step loop
An RL trainer owns its loop: it dispatches batch after batch from a prompt stream, knows
the next batch or two ahead of time, and may still be claiming for a batch it dispatched
two steps ago. LookaheadWarmer keeps the pools sized for exactly the batches in flight:
from agent_sandbox_rl import LookaheadWarmer
warmer = LookaheadWarmer(fleet, keep_finished=max_staleness) # cap = max_warmpool_size
warmer.add(0, {img: prompts * G for img, prompts in batch0}, wait=True) # start-up barrier
warmer.add(1, counts_of(batch1)) # lookahead: pools warm while step 0 runs
... # dispatch, acquire(task) per episode
warmer.add(2, counts_of(batch2)); warmer.finish(0) # batch 0 released: its pools stay for
# keep_finished more finishes, then go
warmer.close()
Each add / finish diffs the desired replicas (sum over live batches plus the kept
finished ones, capped) against what was asked before and applies it concurrently:
warm_image(wait=False) for new images, set_pool_replicas for changed counts,
unwarm_image for images no batch needs. Unknown images are added to the plan first
(fleet.add_tasks). Warm errors land in the returned WarmReport and the log, not in
your step; fail_fast=True re-raises a FleetError from a pool create after the phase.
wait=True is a best-effort barrier (fleet.wait_pools_ready): a pool that exists but
is slow reports ready[img] == False instead of raising, and add_async does the same
on a background thread.
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-container pods
A template can add containers next to the runtime (via extra_pod_spec["containers"],
merged by name), for example a fresh container of the task image that grades the
episode without seeing the agent’s scratch files. Give such a sidecar a command that
stays up (the template’s keepalive, ["sleep", "infinity"]): a task image’s own
entrypoint exits at once and the pod never becomes Ready. Every exec names its
container, because the exec API has no default once a pod has two; the name comes
from the handle or from the call:
h.exec("pytest -q") # the runtime container (agent-runtime)
h.exec("pytest -q", container="eval") # one call in the sidecar
h.target_container = "eval" # every exec / session of this handle from now on
h.open_session() # a session is bound to one container; switching
# containers closes the previous session
make_fleet_repo_env(h, container="eval") # R2E-Gym env whose exec/copy target the sidecar
h.runtime_container is the container that runs the task image (agent-runtime for a
template the fleet wrote; the discovered name for an adopted one). Recycling
(recycle=True) primes and resets the workspace there regardless of target_container.
SandboxHandle.target_container is also what the R2E-Gym adapter uses when
make_fleet_repo_env is not given a container, so a framework that holds one handle
per episode can point grading at the sidecar without touching adapter internals.
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.)
Adopting warm pools someone else provisioned
If the pools are already standing — put there by the multi-cluster fleet layer, a
platform team, or an earlier run — set adopt_existing=True and this package will
use them instead of building its own:
fleet = SandboxFleet(FleetConfig(clusters=[...], adopt_existing=True))
fleet.load_tasks(source)
fleet.setup() # discovers, verifies, creates nothing
Matching is by image, not by name: every SandboxWarmPool in the namespace is
resolved through its sandboxTemplateRef to the image its template runs, so a
pool named by any scheme is found. (This matters — the fleet layer names pools
<template>-pool while this package names them pool-<template>, so a harness
that only knew its own scheme found none of them.)
In adopt mode nothing in the namespace is written: no template is created or
relabelled, no pool is created, scaled or deleted, and teardown leaves both in
place while still releasing the claims this run made. A task image that no
pool serves raises PoolNotFoundError at plan(), naming the images and the
pool count found per cluster, rather than quietly provisioning a size-1 pool per
image — a working-but-far-slower run alongside warm pods it never touched.
Two consequences worth knowing: adopted replicas don’t count towards
active_replicas or the circuit breaker’s intent ceiling (they aren’t this run’s
to over-create, and their pods carry the provisioner’s labels, not its run-id —
only an explicit max_live_sandboxes still applies), and set_pool_replicas /
unwarm_image become no-ops, so recycle(scale_on_hold=True) and the sliding
window won’t resize a pool they don’t own.
If you’d rather keep creating pools but line their names up with an existing
convention, set pool_name_format on its own — "{template}-pool" for the fleet
layer’s scheme, "warm-{image_hash}" for a bare digest. Override
FleetConfig.pool_name(image) in a subclass for anything the format string can’t
express; every pool name in the SDK goes through it.
Concurrent runs on one cluster
Several fleets can run at once on the same cluster. Teardown only ever deletes
this run’s claims, pools and templates (everything carries the fleet.run_id
label), and a pool deleted out from under a wait fails fast instead of running out
ready_timeout. What you still choose is how runs stay out of each other’s way:
template and pool names derive from the image, so two runs on the same image in one
namespace would otherwise share — and resize, and delete — one pool.
run_isolation="names"— everyone stays in one namespace; the run id is baked into every template and pool name (oh-img-<run id>-<md5>). Cheapest to operate: no extra namespaces, quotas or queues. Put{run_id}intemplate_name_prefix/pool_name_formatto place it yourself.run_isolation="namespace"— each run gets<namespace>-<run id>, created on first use (preflight()/plan()) and deleted at teardown if the fleet created it; names stay stable per image. Anything a fresh namespace needs beyond labels — a KueueLocalQueue, aResourceQuota, an image-pull secret — is yours to add inrun_namespace_setup=lambda cluster, ns: ...;run_namespace_labelsgo on the namespace object. The hook can run again on a namespace it partly set up (after a failure whose rollback could not delete the namespace), so make it idempotent. The fleet’s identity needsnamespacescreate/delete.run_isolation="none"(default) — today’s naming; fine when nothing else runs in the namespace.
In every mode a pool or template labelled with another run’s id is never written
to. Warming onto it fails with a FleetError that names the owning run: pick
run_isolation, set adopt_existing=True to share on purpose, or reap the other
run if it is dead. unwarm_image() and set_pool_replicas() leave it alone, each
logging which run owns it. The writes are conditional on what was inspected (uid precondition on
delete, resourceVersion on the resize patch, ownership re-checked at a 409 on
create), so two runs racing on one name cannot delete or resize each other’s pool.
Sharing one warm fleet across consumers on purpose is the
adoption model, not a name collision.
One job, several processes. Each fleet gets a random run id, so an
orchestrator that warms pools and workers that claim from them are separate runs
to each other: a worker’s on-demand pool blocks the orchestrator’s later warm of
that image. Give every process in the job the same FleetConfig(run_id="…") (a
DNS-1123 label) and they share the job’s templates and pools;
run_isolation="names" / "namespace" then isolate the job as a whole. A pool
is deleted only by the process that created it: a process warming an image whose
pool another process already warmed borrows it (claims from it, never resizes or
deletes it), and the first warm of an image a worker provisioned on demand takes
that pool over. So each process’s teardown() releases its own claims and
deletes only the pools it created; on-demand pools nobody took over are left for
the reaper. A per-run namespace is kept unless teardown(delete_namespace=True),
and every process runs run_namespace_setup once (it must already be safe to
re-run). Setting run_id means shared even in a single process, and turns off
the pod-count circuit breaker, which can’t attribute a job-wide pod count to one
process; max_live_sandboxes still caps each process’s claims. Clean up the job
once, at the end, with reap(run_id=…) — not from each process’s exit hook,
where it would sweep the other processes’ resources too.
Many users, one cluster
When several people share a cluster, give each a namespace of their own and keep
the task images shared. The image layers live in the node’s image cache, below
the namespace, so the second user on a node starts without a pull; the running
pods live in the namespace, so the quota, the RBAC and the network policy are
per user. examples/multi_tenant is the
worked example: an admin provisions rl-<user> (Role, ResourceQuota,
LimitRange, one NetworkPolicy) and pre-pulls the image list once; each user
runs experiments in their namespace with run_isolation="names", and the
runner lowers its concurrency to what the namespace quota admits before it
starts, because a quota rejection happens at pod admission, where fail-fast
sees no pod.
| Layer | Shared across user namespaces |
|---|---|
Image layers on the node (IfNotPresent, fleet.prepull(), GKE Image Streaming, secondary boot disks) |
yes, whoever pulled them |
SandboxTemplate, SandboxWarmPool, warm pods |
no, a claim binds only to a pool in its own namespace |
SandboxClaim, Sandbox, pods, quota, RBAC, NetworkPolicy |
no, that is the isolation |
Compared with the layouts above: one namespace with hash names (the default) or
with run_isolation="names" shares the quota and lets anyone delete anyone’s
objects; a shared run_id or adopt_existing is the only layout that shares
warm pods; run_isolation="namespace" gives each run a namespace but needs
cluster rights to create it. A namespace per user costs the sharing of warm
pods and nothing else: two users warming the same image hold two pools, on
image layers they share. With several clusters, the namespace, the quota and
the pre-pull are needed on each cluster, and the example’s “Several clusters”
section shows the same commands with a context list.
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), claim_timeout (None = ready_timeout; bounds one claim’s readiness wait), fail_fast (FailFastPolicy: enabled True, initial_delay_s 10, poll_s 5, grace_s None, unschedulable_grace_s None — set either to also fail the transient reasons / Unschedulable after that many seconds), 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-), pool_name_format
(pool-{template}; {image_hash} and {run_id} also available), adopt_existing
(False — use pools that already exist and fail loudly on a miss, see
above), run_isolation (none |
names | namespace, see above) with
run_namespace_labels and run_namespace_setup, run_id (None = random per fleet;
set it to share one across a job’s processes), 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;
off when run_id is set, see above);
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.
Claims, pools and templates carry it as RUN_ID_LABEL (agents.x-k8s.io/asrl-run-id);
pods carry it as POD_RUN_ID_LABEL (agent-sandbox-rl/run-id), because the Sandbox
controller strips agents.x-k8s.io/* labels from pods — select pods by that key
(fleet.pod_run_selector()). Sandboxes carry no run id, and neither do pods created
before that key existed, so the reaper also reaches them through each pool’s
status.selector and each claim’s Sandbox. An external reaper that knows which
drivers are still up calls reap_orphans(alive_run_ids) (--orphans --alive-run-ids a,b) to sweep every run it does not list, with min_age_s (300)
protecting runs that just started. A run-scoped reap (and reap_orphans) keeps any pool,
with its template, Sandboxes and pods, that a claim of another live run still points at,
so a run that adopted an older run’s pool keeps it. reap("") is rejected rather than
treated as “all runs”. 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,
snapshots (False — GKE Pod Snapshots; see Snapshots).
TemplateSpec: resources (cpu/memory requests; optional cpu_limit /
memory_limit), keepalive_command (sleep infinity),
runtime_class, node_selector, image_pull_secret, image_pull_policy
(IfNotPresent — reuses the node layer cache across epochs; also filled in on
{task_image} containers that name none), colocate_replicas
(False — prefer scheduling a pool’s replicas on one node for cache reuse),
automount_service_account_token / share_process_namespace (both False, stated in
the rendered spec — the controller already defaults the token off and Kubernetes the
shared PID namespace off; set True for a workload that needs them), extra_pod_spec (a container or
init container whose image is {task_image} runs the template’s own task image — the
way to declare a fresh grading sidecar next to the runtime container),
extra_template_spec (extra SandboxTemplate spec keys; podTemplate and
networkPolicyManagement have their own knobs and are rejected),
network_policy_management (None — the controller’s default, one
NetworkPolicy per template; "Unmanaged" to run one namespace-wide policy on the
shared app=agent-sandbox-rl pod label instead, and preflight() warns if no policy
selects the fleet’s pods; see
examples/agent-sandbox-rl-network-policy).
Dataset rows and image manifests: load_tasks takes a TaskSource, a list of
images, Tasks, or dataset rows (dicts). A row’s image is found under the SDK’s
image key or the R2E-Gym / SWE-bench conventions (docker_image, image_name,
metadata.docker_image, metadata.env_config.entry.docker_image, …; one-element array
values are unwrapped), and its id under id / instance_id / task_id; pass
image_field= (a dotted path, several, or a callable) and id_field= to override. The
row stays in Task.metadata minus the keys the id and image were read from.
ImageListSource(path) / load_image_list(path) read a one-image-per-line manifest from
a local path or gs:// (the storage client library if installed, else gcloud storage cat).
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; on clusters withsnapshots=Truealso the PodSnapshot CRDs, a gVisor runtime class, and (warning) aPodSnapshotPolicyin the namespace that groups byagents.x-k8s.io/sandbox-name-hash— a policy grouped by another label is named in the warning. Hard failures raisePreflightError; soft issues are warnings. - Fail fast on claims that cannot start: the SDK’s claim wait sees only the
claim, so a pod stuck in
ImagePullBackOff,CreateContainerConfigError,CrashLoopBackOff, OOM-killed at start, orUnschedulableused to run outready_timeout(15 min) before the caller heard anything.acquire()now runs a watchdog on the claim’s pod and raisesSandboxStartError(with the Kubernetes reason, pod and image) as soon as the pod is in a state the controller will not recover from: at once for reasons that never clear, and, opt-in, afterFailFastPolicy.grace_sfor ones that can be transient and afterunschedulable_grace_sfor scheduling (unset by default: a GPU or node auto-provisioning scale-up outlasts a few minutes ofUnschedulable, and failing a throttled pull only moves the load to a new pod). A pod being deleted is not a verdict; the controller recreates it.FleetConfig.claim_timeoutbounds one claim’s wait separately from the pool-fillready_timeout. A pull that flips betweenErrImagePullandImagePullBackOff, or a crash loop seen between restarts, keeps one grace clock per container. Mid-episode,handle.exec()raisesSandboxLostErrorwhen the pod is gone (one pod read after a transport error), so a loop can release and re-acquire instead of spending its step timeout on a dead pod.handle.is_alive()also reports a pod the controller recreated under the same name, or a runtime container that restarted in place:acquire()records the pod’s UID and restart count. A one-shotexec()against such a replacement succeeds without a transport error, so callis_alive()at step boundaries when lost state must not go unnoticed. - Pre-pull (
fleet.prepull()/setup(prepull=True)): DaemonSets cache task images on every node so warm pools skip the multi-GB pull. Images are split into DaemonSets ofprepull_images_per_pod(25) images, at mostprepull_max_parallel(4) of them pulling at once per node (withwait=True); the pods inherit the template’s node selector, runtime class, pull secret and tolerations, so they land on tainted sandbox pools.prepull_delete_when_doneremoves each DaemonSet as its nodes finish (a batch that missed the budget stays so its pull errors can be read; otherwise they stay and cover new nodes;fleet.prepull_delete()removes every pre-pull DaemonSet in the namespace, of any run), andprepull_drop_cachesends with a privileged one-shot DaemonSet that drops the page cache, for runs that must start cold (a namespace under Pod Security Admissionbaselineorrestrictedrejects that pod; the pre-pull then reportsFalseat once instead of spending its budget).setup(prepull=True)logs a warning when the budget runs out and starts the warm pools anyway. 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).teardown(verify=True)(orverify_teardown()afterwards) then counts this run’s pods and runs the run-scoped reaper on every cluster, which deletes whatever of this run is still there (a delete that failed, a pod the controller had not removed yet) and returns{cluster: counts}of claims, pools, sandboxes, templates and pods; all-zero counts mean the teardown was clean. Skipped, with a note, for a sharedrun_id,adopt_existing, and a namespace the teardown deleted; it refuses to run beforeteardown(), because it deletes.
Snapshots — suspend / resume / restore (GKE, opt-in)
On GKE clusters with Pod Snapshots
a claimed sandbox can be snapshotted (memory + filesystem), suspended, resumed,
and restored — park a long-running episode between turns and free the node, or
checkpoint an episode at a decision point and roll back to it. The fleet exposes
the k8s-agent-sandbox SDK’s GKE extension on its handles; nothing about pools,
sizing, or strategies changes.
Prerequisites (cluster side; the SDK creates only the per-snapshot triggers):
a GKE cluster on 1.35.3-gke.1234000 or later with Pod Snapshots enabled (see
Prepare for Pod snapshots)
and a gVisor runtime class;
a PodSnapshotStorageConfig (GCS bucket, tokenSource: podKSA) and a
PodSnapshotPolicy whose grouping rules include agents.x-k8s.io/sandbox-name-hash
and whose selector matches your template’s pod labels (every fleet pod carries
app=agent-sandbox-rl and sandbox=<template-name>); a pod ServiceAccount with the
Workload Identity bucket grants (storage.bucketViewer + storage.objectUser); and
storage.objectUser on the same bucket for GKE’s service agent
(service-<PROJECT_NUMBER>@container-engine-robot.iam.gserviceaccount.com), which is
what deletes snapshot objects — without that grant deleted PodSnapshots hang in
Terminating on their finalizer, so release(h, delete_snapshots=True) burns its full
timeout per sandbox and leaks the snapshots. See
examples/podsnapshot-golden-warmpool
for the exact bindings, and the SDK extension’s README and the
site docs for manifests.
fleet = SandboxFleet(FleetConfig(
clusters=[ClusterConfig(name="gke", namespace="rl", snapshots=True)], # snapshot-capable client
template=TemplateSpec(runtime_class="gvisor",
extra_pod_spec={"serviceAccountName": "podsnap-sa"})))
fleet.load_tasks(["python:3.12"]); fleet.setup()
h = fleet.acquire(fleet.tasks[0])
h.exec("echo hello > /tmp/state && nohup bash -c 'while sleep 1; do :; done' >/dev/null 2>&1 &")
uid = fleet.snapshot(h, "checkpoint") # memory + filesystem, sandbox keeps running
fleet.suspend(h) # snapshot again, then delete the pod (claim kept)
fleet.resume(h) # new pod restored from the LATEST snapshot
h.exec("cat /tmp/state") # -> hello ; the loop is still running
fleet.suspend(h, snapshot=False) # no new snapshot; resume() would restore the latest one
fleet.restore(h, uid) # roll back to the named checkpoint
fleet.release(h, delete_snapshots=True) # snapshots outlive the claim otherwise
suspend(snapshot=False) skips the capture but does not discard older snapshots:
resume() restores the latest one and cold-boots the template only when none exists.
For a guaranteed cold boot, call h.delete_snapshots() before suspending.
release(delete_snapshots=True) deletes the snapshots only after the claim is gone, so
a failed release keeps them for the retry.
The same methods exist on the handle (h.snapshot(), h.suspend(), h.resume(),
h.restore(uid), h.list_snapshots(), h.delete_snapshots(), h.is_suspended); the
fleet wrappers add run-report phases (snapshot/suspend/resume/restore) and a
restored/cold resume count. Outside fleet.run(), wrap primitive calls in
with fleet.recording("label") as report: to get that report.
examples/run_snapshot_demo.py runs this flow end to end and asserts that both the
rootfs file and a background process survive.
What to know:
- A resume or restore is a new pod.
pod_nameandpod_ipchange (the handlerefresh()es itself);hostname/sandbox_id/claim_nameare stable. A persistent exec session is dropped —exec()falls back to one-shot, callopen_session()again to re-attach. Code that cachedpod_nameitself must re-read it. - Quiesce before suspending. An in-flight command is frozen inside the snapshot and resumes into whatever timeout it had.
- Failures raise. The SDK returns result objects; the fleet raises
SnapshotErrorwith the extension’s reason, andSnapshotsUnavailableon a cluster/handle without the snapshot client. - Pinning.
fleet.acquire(task, snapshot_uid=uid)stampspodsnapshot.gke.io/ps-nameon the pod so a claim boots from that snapshot instead of the group’s latest. It applies at pod creation: a cold start honors it, an adopted already-running warm member does not — and warm adoption is the common path. The fleet reads the new pod’sPodRestoredcondition after a pinned acquire: when the pin did not apply it logs at INFO and setshandle.pending_snapshot_uid, so the caller canfleet.suspend(h)+fleet.resume(h)(orfleet.restore(h, uid)) to apply it; a resume or restore clears the marker. - Bookkeeping.
fleet.suspend(h)gives the claim slot back: a suspended sandbox has no pod, so it stops counting towardmax_live_sandboxesand the cluster’s claim count, andfleet.resume/fleet.restoretake a slot again (raisingFleetOvercommitErrorat the cap before touching the sandbox). Parking 10K handles between turns therefore lets the loop acquire 10K new ones. Suspension creates no warm capacity, though: the claim still holds its sandbox, so the pool does not refill for it. The bareh.suspend()skips this bookkeeping. Snapshots persist after release unlessdelete_snapshots=True; the policy’s retention bounds them per sandbox, and the release lists them by the sandbox’s label with a 10 s timeout (kept, with a warning, when that fails). - Async. The SDK calls block.
AsyncSandboxFleethas the samesnapshot/suspend/resume/restore(andacquire(snapshot_uid=…),release(delete_snapshots=…)) running on its thread pool — use those rather than the handle methods from an event loop. - Scope. GKE only. Restore targets a sandbox’s own snapshots; sharing one
snapshot across a warm pool is a policy-level pattern (see upstream
examples/podsnapshot-golden-warmpool).
Limits and failure modes the fakes in the tests cannot show:
- A wedged sandbox survives restore. A snapshot taken of a hung sandbox restores a
hung sandbox. Plan one-strike eviction:
release(h)and acquire a new one rather than retryingrestoreon the same handle. - A timed-out trigger blocks the next suspend. A
PodSnapshotManualTriggerthat was not processed withintimeout=stays on the cluster (the SDK deletes its triggers only when the sandbox is terminated) and blocks the next snapshot or suspend of that sandbox until it is deleted:kubectl -n <ns> delete podsnapshotmanualtrigger <name>(the name starts with the sandbox id). - Propagation has its own clock.
resumeandrestorewait up to 30 s, independent oftimeout=, for the pin annotation to propagate from the claim to the Sandbox. When that lapses the SDK reportsInternal Error: Timed out waiting for …and the fleet raisesSnapshotErrorwith it. This is controller lag, not a caller mistake: retry the call. - The policy’s grouping label decides what a restore finds. A policy grouped by any
label other than
agents.x-k8s.io/sandbox-name-hashmakes triggers fail with an opaquerunsc error: exit status 128;preflight()names such policies.
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=agent-sandbox-rl 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 100 workers and SandboxClaim at 50 — so a
1000-wide max_concurrent run can still queue behind reconcile concurrency. 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 your integration without a cluster
agent_sandbox_rl.testing.FakeFleet is the SandboxFleet surface with no API server
behind it: the same methods with the same signatures (load_tasks / add_tasks /
plan / setup, warm_image / warm_images / set_pool_replicas / unwarm_image /
wait_pools_ready, acquire / release / run / recording, the snapshot wrappers,
teardown), so a test that passes on the fake does not call something the real fleet
lacks, and a LookaheadWarmer runs against it unchanged. Plans are sized with
the real sizing.compute_replicas, run() executes the real strategies over the fake
(warming order, teardown and the RunReport are the fleet’s), the claim slot moves on
suspend / resume as it does for real, and the fleet’s exceptions are raised where a
loop can observe them (FleetOvercommitError at max_live_sandboxes, KeyError for
an unplanned warm_image, a torn-down fleet on acquire). On top of that surface it
adds inspection only: warm_calls, scale_calls, unwarm_calls, wait_calls,
add_tasks_calls, acquire_calls, release_calls, active_pools,
FakeHandle.commands / exec_calls (each command with the container it ran in), and
FakeSandboxGone (exec or snapshot on a released or suspended handle, a guard the real
handle does not have). Inject failures
with fail_warm / fail_acquire and program exec with exec_outputs:
from agent_sandbox_rl import FleetConfig
from agent_sandbox_rl.testing import FakeFleet
fleet = FakeFleet(FleetConfig(warm_per_task=True, max_warmpool_size=4),
exec_outputs={"pytest": "3 passed\n"})
fleet.load_tasks(rows); fleet.setup()
my_trainer_step(fleet) # the code under test, unchanged
assert fleet.active_pools and fleet.release_calls
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.