How much to run
Ask before you guess.recommend() sizes the run from the agent’s own covering grid and from published post-training practice (FireAct, LIMA, AgentTuning for SFT; DAPO, Skywork-OR1 for RL). No key, no network.
target= sets how many selected rows you want (default 800). mode="rl" assumes half the prompts produce a mixed group (mixed_rate=0.5). That rate is the agent’s, not ours: probe 12 asks, grade, read group_signal, and pass the measured number back as mixed_rate=. A low rate means the grid is too easy for this agent; aim it with traces= before buying rollouts.
How mode="rl" spends rollouts
mode="rl" allocates rollouts successively. Every prompt is probed with two rollouts, the least that can show a split. A prompt whose rollouts disagree is filled to k, because that is the only place a grouped update has a gradient. A prompt that stays unanimous gets one more rollout only while the chance the next one differs beats what a fresh prompt offers per rollout. Both sides are measured on the run: the hazard is how often a group unanimous after n rollouts split on its next one (1/(n+2) only as the prior), the fresh side is the run’s mixed rate over the probe. When nothing fresh can be opened, unanimous groups are resumed and finished.
Pass grader= and it runs beside the rollouts as they land, never in front of them; a prompt’s decision waits for its verdict, so the allocation reads rewards, the signal a grouped update trains on. Without a grader it reads behavior signatures, which split more often than the judge does.
Near the end of a time_budget the run stops opening groups and finishes the rollouts in flight; a group still short of k at the whistle is stamped group_cut.
data.search["groups"] reports mixed, stopped_unanimous, complete, partial, rollouts_saved, the measured mixed_rate and the hazard per group size. data.pass_at scores stopped unanimous groups as unanimous. repeat_policy="fixed" restores k rollouts for every prompt (and then search["groups"] is not written); advanced={"probe": n} changes the probe. This is DAPO’s dynamic sampling (arXiv 2503.14476) and difficulty filtering (rlhfbook.com/c/07-reasoning), applied at generation time.
Close the loop: aim the budget with traces
This is the second half of “two ways in, one engine” on the overview. Withouttraces=, the coverage grid comes from the agent’s tools and policy alone: a cold start that samples the whole space evenly. With traces=, the grid is aimed at the tools, faults and worlds the deployed agent actually got wrong, so new rows land where the agent is weak.
A trace is a plain row dict. Not an OTLP span, not a platform dataset id, not anything you have to ingest first. It is the same shape grade() returns and the same shape simulate() writes:
prompt (or messages) and steps are what matter. reward is optional (ungraded traces still focus the grid, they just carry less signal), and a JSONL path works anywhere a list does. load_traces normalizes the common variants (tool_trace/trace for steps, final/output/response for final_text, OpenAI-style messages), so exports from other stacks usually drop straight in. OTLP ingest and platform datasets are one way to get rows into this shape, not a prerequisite for it.
trace_report prints:
The leakage rule. Source traces shape the grid and never enter the generated dataset;
simulate(traces=...) already drops generated rows that near-copy a source. leakage_report / drop_leaky_rows are how you verify it, which is what makes it safe to hold traces out for evaluation:
evaluate(rollouts, judge).failed_traces() hands the failures straight back to simulate(traces=...).
What traces can and cannot aim at. Traces reproduce situations: the tools, faults and world states the deployed agent met. A failure that has a world-visible trigger (a tool timed out and the agent did not say so, a stale record was presented as current) is reproduced. A failure that lives in how the reply is worded (an unsupported claim, an estimate not labelled as one, two questions where one was asked for) has no trigger in the world, so traces alone cannot aim at it. Measured on a 12-rule grader, every rule with a tool-result trigger was reproduced and every rule about the reply’s wording was not. For those, put the grader in the loop: with simulate(..., grader=judge) a row the grader fails is re-rolled and its ask mutated like a tool fault, and data.search["mutation_aims"] says how many parents and mutated rows each aim (world_fault, graded_failure) produced. The grader is the switch; to grade beside the loop and still steer by tool faults alone, pass advanced={"mutate_graded_failures": False}.
If your traces are already on the platform, wai.cut(agent="my-agent") does the whole cut in one line: the platform page.
Examples
In the order a post-training run happens. The index atrecipes/README.md has one line per recipe with what it needs and how long it takes. “Offline” below means no key and no network.