Guides / Python / RL rollouts and evals
RL rollouts and evals
An eval or an RL run needs many copies of one environment, each sealed from the others and from your machine, reset cheaply between episodes. A kosa8 sandbox is that copy; a snapshot is the reset.
Uses kosa8 0.2.0 (run_code, shell, files, lifecycle)
and calls that 0.1.x already had (fork, checkpoint,
rewind, explore). The patterns below are built from those calls; they
have not been benchmarked here, so measure the timings on your machine.
The shape of it
- Prepare once. Make a sandbox, install the task's dependencies, copy in the repo. Save it as a template or keep it as a snapshot.
- Fan out.
fork(n)makes n running copies of that state. - Roll out. Each policy or model works in its own copy through the toolbox.
- Score. Run the hidden tests in the copy and read the exit code.
- Throw away. Delete the copies. The prepared state is untouched.
1. Prepare the environment
from kosa8 import Kosa8Client
client = Kosa8Client()
base = client.create_sandbox(name="task-base", egress_allow=["pypi.org", "files.pythonhosted.org"])
sb = client.sandbox(base["id"])
sb.git("clone", url="https://github.com/pallets/click", path="repo")
sb.shell("cd /workspace/repo && pip install -q -e . pytest")
For an environment you will reuse across runs, put the setup in a Dockerfile and build a
template once: client.build_template("click-env", "./env"). Then every sandbox
starts from it with create_sandbox(template="click-env"), with no install step.
2. Fork one rollout per sample
f = client.fork(base["id"], 8, prefix="ep-") episodes = [x["id"] for x in f["forks"]] # ep-1 … ep-8, all running, all identical
Forking snapshots the base once and restores it eight times. The base keeps running. Each fork has its own disk, memory and network rules, so one episode cannot see or spoil another.
3. Run the policy in each copy
Give each episode its own tools. With the OpenAI Agents SDK (see agent frameworks):
from concurrent.futures import ThreadPoolExecutor
from agents import Agent, Runner
from kosa8.integrations.openai_agents import sandbox_tools
TASK = "Fix the failing test in /workspace/repo. Do not edit the tests."
def rollout(sid):
agent = Agent(name="solver", instructions=TASK, tools=sandbox_tools(client, sid))
return Runner.run_sync(agent, TASK, max_turns=30).final_output
with ThreadPoolExecutor(8) as pool:
transcripts = list(pool.map(rollout, episodes))
The client is safe to share between threads; each call is its own request.
4. Score with a reward the agent cannot see
Copy the grader in only after the rollout, so the policy cannot read or edit it, and score on the exit code:
GRADER = open("hidden_tests.py").read()
def reward(sid):
sb = client.sandbox(sid)
sb.write_file("/workspace/.grader/test_hidden.py", GRADER)
r = sb.shell("cd /workspace/repo && python -m pytest -q /workspace/.grader", timeout_s=300)
return 1.0 if r.ok else 0.0
rewards = [reward(sid) for sid in episodes]
For a partial score, have the grader print JSON and parse r.stdout. With
run_code you can score in Python directly and get back charts as PNG bytes in
r.artifacts.
5. Clean up
for sid in episodes:
client.delete_sandbox(sid)
Or let kosa8 do it: give the base ttl_min= or auto_stop_min=, and
make throwaway sandboxes ephemeral=True, so they are deleted the moment they stop,
even if your script crashes.
Branching inside an episode
For search (tree search, best-of-N per step, undoing a bad action), take a checkpoint before a risky step and rewind if it fails. The rest of the episode is unaffected:
client.checkpoint("ep-1", label="before migration")
r = client.shell("ep-1", "cd /workspace/repo && ./migrate.sh")
if not r.ok:
client.rewind("ep-1") # back to the checkpoint: disk and memory
To try several next actions at once and keep the copy where one worked, fork the episode once per action, run each in its own copy, and keep the first that passes:
patches = ["patch-a.diff", "patch-b.diff", "patch-c.diff"]
tries = [x["id"] for x in client.fork("ep-1", len(patches), prefix="ep-1-try")["forks"]]
with ThreadPoolExecutor(len(tries)) as pool:
oks = list(pool.map(lambda t: client.shell(
t[0], f"cd /workspace/repo && git apply /workspace/{t[1]} && python -m pytest -q").ok,
zip(tries, patches)))
winner = next((sid for sid, ok in zip(tries, oks) if ok), None)
for sid in tries:
if sid != winner:
client.delete_sandbox(sid)
explore() does this in one call for commands that need only an image, not the
workspace: each variant runs in a fresh container of image, and it keeps the fastest
copy that exits 0. See explore in depth.
Evals over a dataset
The same pattern runs a benchmark: one forked sandbox per task, the model's answer applied, the task's tests run, the pass rate counted.
results = []
for task in dataset: # each: {"id", "setup", "check"}
sid = client.fork(base["id"], 1, prefix=f"eval-{task['id']}-")["forks"][0]["id"]
try:
client.shell(sid, task["setup"])
answer = model_solve(task, sandbox_tools(client, sid))
results.append(client.shell(sid, task["check"]).ok)
finally:
client.delete_sandbox(sid)
print(f"pass rate {sum(results)}/{len(results)}")
Things to watch
- Network: sandboxes start with the network closed. Open only what setup needs, and consider a separate base for rollouts with egress denied, so a policy cannot fetch answers.
- Resources: every fork is a microVM with its own memory. Size
mem_mbon the base, and keep the number of live forks to what the machine holds. - Time: pass
timeout_stoshellandrun_code, so a hung episode ends withtimed_out=Truerather than blocking the batch. - Audit: every command an agent ran is in the kosa8 audit log, which helps when a reward looks too good.
See also: explore and fork in depth and explore.