Branch & Explore
Best-of-N over sub-second copy-on-write forks — try N approaches in parallel real execution and keep the winner. Tree-of-thought for agents, over the world instead of over tokens.
AI agents are tree search. When an agent hits a fork in the road — which fix? which library? which algorithm? — the strongest thing it can do is try several and keep what works. explore() makes that one call: it forks the sandbox N ways, runs a different approach in each branch in parallel, scores the outcomes, and promotes the winner — reaping the losers automatically.
result = sandbox.explore(
["python solve.py --strategy greedy",
"python solve.py --strategy dp",
"python solve.py --strategy beam"], # the list IS the fan-out
score_fn=lambda sb, o: 1.0 if o.ok and "PASS" in o.stdout_tail else 0.0,
)
winner = result.winner # already promoted, independent; losers reapedThis is only practical because PandaStack forks are sub-second copy-on-write — reflink rootfs + memory restore, ~400–750ms same-host, sharing the parent's memory until written. Cold-booting N containers to do the same would be 10× slower and 10× the RAM. Branch-and-explore is best-of-N over real execution, not over re-sampled tokens.
The best-of-N code fix
The canonical use: an agent has a failing test. It generates a few candidate patches, applies each in its own branch, runs the test, and promotes the branch that turns green — the winning sandbox is the fixed workspace.
from pandastack import Sandbox, Candidate
sb.filesystem.write("/work/app.py", buggy_code)
def patch(label, code):
def body(branch):
branch.filesystem.write("/work/app.py", code)
return branch.exec("cd /work && pytest -q", timeout_seconds=60)
return Candidate.call(label, body)
result = sb.explore(
[patch("guard-zero", fix_a), patch("try-except", fix_b), patch("or-1", fix_c)],
score_fn=lambda branch, o: 1.0 if (o.value and o.value.exit_code == 0) else 0.0,
)
print(result.table())
winner = result.winner # the patched, test-passing sandboximport { Candidate } from "@pandastack/sdk";
await sb.filesystem.write("/work/app.py", buggyCode);
const patch = (label: string, code: string): Candidate => ({
label,
fn: async (branch) => {
await branch.filesystem.write("/work/app.py", code);
return branch.exec("cd /work && pytest -q", { timeoutSeconds: 60 });
},
});
const result = await sb.explore(
[patch("guard-zero", fixA), patch("try-except", fixB), patch("or-1", fixC)],
{ scoreFn: (_b, o) => ((o.value as any)?.exit_code === 0 ? 1 : 0) },
);
const winner = result.winner; // the patched, test-passing sandboxHow it works
| Step | What happens |
|---|---|
| 1. Fork | One fork-tree call spawns a copy-on-write fork per candidate, in parallel server-side. The candidate list length is the branch count — there's no separate number to drift out of sync. |
| 2. Run | Each candidate runs in its own branch (bounded concurrency). A candidate is a shell command, or a fn that drives the forked sandbox directly (write files, run anything). |
| 3. Score | Each branch produces a BranchOutcome (exit code, stdout/stderr tails, files changed, duration). Your score_fn ranks them — or omit it and "the first branch that exits 0 wins" is the default. |
| 4. Pick & promote | The highest-scoring viable branch is promoted to an independent sandbox. By default an errored branch never wins (require_success). |
| 5. Reap | The losing forks are deleted automatically (they cost RAM). If your process dies mid-experiment, a TTL stamp lets the server reap orphans. |
Candidates
A candidate is either a shell command or a function that gets the live forked sandbox:
"npm test"— a bare string is shorthand for a shell candidate.Candidate.shell("label", "cmd", env={...}, timeout_s=120)— a labeled shell command.Candidate.call("label", fn)— full control:fn(branch)writes files, runs multiple commands, returns any value (surfaced asoutcome.value).
The outcome
Every branch returns a BranchOutcome the agent can reason over without reading raw logs:
ok # body completed AND (cmd-mode) exit code == 0
score # your score_fn, or 1.0/0.0 from ok
exit_code # the command's exit code (None in fn-mode)
stdout_tail # last ~4 KiB
stderr_tail # last ~4 KiB
files_changed # files the branch touched (stat-based, capped) — NOT a block diff
duration_ms
value # fn-mode return value
error # spawn/exec/timeout/body failure, else Noneresult.outcomes is sorted best→worst; result.table() renders a compact summary for the agent's reasoning or your logs.
Limits & cost
- Same-host. Forks are copy-on-write off a local block device, so all branches live on the parent's host. The branch count is capped at 16 per experiment (
explore()raises before forking if you pass more — split into rounds). - RAM-bounded. Each branch is a live microVM. The binding constraint is the host's memory, not a slot count — keep N sensible for the parent's
memory_mb. max_concurrency(default 4) throttles the run/score phase; the forks themselves all spawn at once.- Always cleaned up. Losers are reaped server-side on promote, with a client-side
killfallback and a TTL backstop — a branch experiment never leaks sandboxes.
Branch-and-explore composes with everything else: each branch can have its own env/secrets inherited from the parent, run a code-interpreter session, or be hibernated. The winner is a normal sandbox you keep working with.