PandaStack

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 reaped

This is only practical because PandaStack forks are sub-second copy-on-writereflink 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 sandbox
import { 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 sandbox

How it works

StepWhat happens
1. ForkOne 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. RunEach 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. ScoreEach 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 & promoteThe highest-scoring viable branch is promoted to an independent sandbox. By default an errored branch never wins (require_success).
5. ReapThe 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 as outcome.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 None

result.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 kill fallback 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.

On this page