Rollout Engine
The task-scheduler-based rollout and evaluation layer: how one trainer iteration turns a batch of prompts into agentic multi-turn trajectories, filters and relabels them, and hands the result back for the loss.
1 Position in the System
Everything in rl_engine/rollout/ answers one question each trainer iteration: given the current student weights, produce the trajectories to train on. The entry point is SchedulerRolloutManager — a Ray actor subclassing slime's RolloutManager that routes generation and evaluation through a TaskScheduler.
This page — Architecture
The manager actor, the directory tour, the episode abstraction, the data path, and the trajectory post-processing lanes.
Task Scheduler sibling
rollout-scheduler.html — batching strategies, PlacementPool resource gating, the batch lifecycle, and the multi-dataset param cascade in depth.
Inference Backend sibling
rollout-inference.html — SGLang serving, weight sync (inject vs. exchange), and GPU memory offload between training and rollout phases.
SchedulerRolloutManager
Ray forbids inheriting from a @ray.remote actor class directly, so rollout_manager.py unwraps the decorator, subclasses the plain base, and re-applies it:
# rl_engine/rollout/rollout_manager.py
from slime.ray.rollout import RolloutManager as _RolloutManagerActor
# Unwrap the @ray.remote decorator to get the plain Python class.
_RolloutManagerBase = _RolloutManagerActor.__ray_metadata__.modified_class
@ray.remote
class SchedulerRolloutManager(_RolloutManagerBase):
# overrides: __init__, dispose, _get_rollout_data, eval (+ helpers)
...
It overrides only what it must, inheriting the rest from slime:
| Overridden | Why |
|---|---|
__init__ | Parse --rollout-config YAML, build the TaskScheduler, and swap in a MultiDataSource when a rollout config is present. |
_get_rollout_data(rollout_id) | The heart of this layer: drive TaskScheduler.execute_rollout(), apply the filters, then either expand DAgger sub-samples or capture teacher logprobs (§5). |
eval(rollout_id, eval_label=None) | Drive TaskScheduler.execute_eval(), then compute per-dataset accuracy and pass@k. |
dispose / prune_env_nodes | API-eval-only safety and between-rollout podman overlay cleanup on env nodes. |
| Inherited unchanged from slime | Role |
|---|---|
generate(rollout_id) | Calls _get_rollout_data(), post-processes rewards, converts to columnar data, and splits by DP rank via _split_train_data_by_dp(). |
_convert_samples_to_train_data, _post_process_rewards, _split_train_data_by_dp | Sample → columnar tensor conversion, reward normalization, DP partitioning. |
| offload / onload, health monitoring, fault tolerance | SGLang lifecycle and actor resiliency — see Inference Backend. |
The output format is list[Box], not a single dict
generate() is inherited unchanged — it returns a list[Box] (one Box per DP rank) that the trainer's process_rollout_data() consumes natively. The manager overrides only what goes into the samples (_get_rollout_data), not this envelope.
Two construction paths
placement_group.py exposes two factories — the default scheduler-backed path, and a legacy bypass for ablation runs:
| Factory | Selected by | Builds |
|---|---|---|
create_rollout_manager(args, pg) | default | SchedulerRolloutManager — full task-scheduler path. Also resolves num_rollout from num_epoch. |
create_legacy_rollout_manager(args, pg) | --use-legacy-rollout | slime's original RolloutManager directly — a training-only path that bypasses the scheduler. |
Top-level architecture
One rollout step flows left to right — data source to scheduler, a short-lived Evaluator per batch, one episode handler per sample, and back out as list[Box]:
One rollout step. Task types (swe, retool, swebench, …) are illustrative; the evaluators present in a run are whatever build_evaluators registers.
2 Directory Tour
Paths are relative to rl_engine/rollout/.
Top level
| Path | Contents |
|---|---|
__init__.py | Exports SchedulerRolloutManager, create_rollout_manager. |
rollout_manager.py | The SchedulerRolloutManager actor: _get_rollout_data, eval, the filters, DAgger sub-sample expansion, and both teacher-capture lanes. |
placement_group.py | create_rollout_manager / create_legacy_rollout_manager (§1). |
multi_data_source.py | MultiDataSource, DatasetEntry, parse_rollout_datasets_config, resolve_dataset_path. |
eval_dataset_config.py | EvalDatasetConfig — slime's config extended with task_type, max_turns, max_context_len. |
termination_summary.py | Failure-class taxonomy + per-iter summary.json writer (§5). |
distill_capture.py | Teacher force-decode (capture_teacher_logprobs_for_samples, score_response_with_topk) with retry/backoff. |
kl_eval_cache.py | Per-iter student eval-trajectory dump (dump_samples_from_eval). |
scheduler/ — the control plane deep dive: Task Scheduler
| Path | Contents |
|---|---|
scheduler/__init__.py | Package exports + build_task_scheduler factory + _build_placement_pool. |
scheduler/task_scheduler.py | TaskScheduler, Batch, BatchingStrategy, fifo_batching. |
scheduler/evaluator.py | EvaluatorSpec (blueprint) + Evaluator (per-batch executor). |
scheduler/resource.py | Resource, NodeSpec, PlacementPool, PlacementToken. |
scheduler/task.py | Task, TaskConfig, StoppingCriteria, TaskStatus. |
agents/ — episode handlers
| Path | Contents |
|---|---|
agents/base.py | BaseAgent (episode handler), AgentContext, AgentResult (§3). |
agents/installed.py | InstalledAgent + InstalledScaffold spec for black-box scaffolds. |
agents/installed_scaffolds/ | openhands.py, terminus2.py (+ in-sandbox terminus2_runner.py). |
The in-house SWE / DAgger handlers (SWEAgent, DAggerSWEAgent) live with their lane in rl_engine/examples/swe_agent/agents/, not here — rollout/agents/ holds only the base contract.
backends/ · proxy/ · sandbox/ · utils/
| Path | Contents |
|---|---|
backends/ | Model I/O: SGLangBackend, OpenAIChatBackend, TeacherBackend, TokenTracker, DAggerTracker, api_pricing. |
proxy/recording_proxy.py | RecordingProxy — a per-task FastAPI proxy that captures tokens for black-box installed scaffolds that never expose token IDs natively. |
sandbox/rock.py | RockSandbox — the ROCK client (start / execute / execute_in_session / stop, leases). See Sandbox Management. |
utils/ | tool_calling, action_format, dagger_schedule. |
3 The Episode Abstraction
The scheduler knows nothing about SWE-Bench, tool calls, or teachers — all of that lives behind one interface, BaseAgent (agents/base.py). A handler owns the entire episode for one sample, so both black-box scaffolds (OpenHands, Terminus-2) and native token-level agents (DAgger) fit one contract.
The three data types
| Type | Role |
|---|---|
BaseAgent | Abstract handler. Async lifecycle init → run → eval → cleanup; subclasses must implement the first three, cleanup defaults to a no-op. |
AgentContext | Per-run inputs the Evaluator populates: args, placement (node/GPU, set only during resource-gated phases), sampling_params, max_turns, max_context_len, timeout, sglang_url. (proxy_url is populated later by the handler, e.g. InstalledAgent._maybe_start_proxy, not by the Evaluator.) |
AgentResult | Episode output: output_text, metadata, task-specific patch / trajectory, and optional token-level fields (prompt_token_ids, per-turn completion_token_ids, per-turn logprobs, loss_mask). |
# agents/base.py — the contract every scaffold implements
class BaseAgent(abc.ABC):
async def init(self, sample, context) -> None: # lightweight, no placement yet
async def run(self, sample, context) -> AgentResult: # the full multi-turn loop
async def eval(self, sample, context, result) -> float | dict: # reward
async def cleanup(self, sample, context) -> None: # finally-block; stop containers
Reward
There is no standalone Reward object: the reward comes from handler.eval() — for SWE, the bench's verifier scored 0/1 — and the Evaluator stores it as sample.reward.
How the Evaluator runs one sample
scheduler/evaluator.py drives the lifecycle in _execute_sample. Three guarantees hold:
- The semaphore spans the whole episode.
async with self._semaphorewrapsinit → run → eval → cleanup, capping live samples atResource.concurrency— not turns. - One placement token spans run and eval. Acquired before
run, released in thefinallyaftercleanup, so the patch and verification containers never double-count against a node's budget. - Failures degrade to a masked dummy. Any exception marks the sample
ABORTEDwith 2-token dummy data ([0, 0],remove_sample=True, zero gradient);classify_abortbuckets it and_annotate_abortattaches diagnostics (phase, elapsed vs. window, node IP).
After eval, _populate_token_data lifts the token-level fields off AgentResult onto the Sample — what makes a trajectory trainable, whether captured natively or through the recording proxy.
The multi-turn loop
Inside run(), a native handler like DAggerSWEAgent (overriding _agent_loop) alternates policy generation with environment steps, tagging every token with a loss mask.
The per-turn loop. Rollback discards the tokens that would overflow the window and ends the episode cleanly rather than truncating mid-token.
Where the loss mask comes from
In DAgger rollins, teacher-executed action tokens get loss_mask=1; student actions and all tool observations get 0. The two rollin protocols (per-turn β-mixture vs. AggreVaTe student-prefix) are selected by STACX_DAGGER_SAMPLING_MODE; how these masks become CE vs. K3 targets is §5 and Algorithm Recipes.
4 The Data Path
Each prompt must reach the right handler with the right stopping criteria. Three pieces cooperate: MultiDataSource serves prompts, DatasetEntry / EvalDatasetConfig carry per-dataset overrides, and task_type routes each sample to an EvaluatorSpec.
MultiDataSource
multi_data_source.py replaces slime's single-file DataSource so one rollout step can span heterogeneous task types. It serves datasets in FIFO order (dataset A exhausted before B). Per sample it deep-copies the prompt, stamps metadata["task_type"] and metadata["_dataset_cfg"] (the full DatasetEntry), and expands n_samples_per_prompt into a group.
parse_rollout_datasets_config builds the DatasetEntry list from YAML; resolve_dataset_path anchors relative paths at the repo root (or STACX_ROOT).
# --rollout-config YAML — parse_rollout_datasets_config()
rollout:
defaults: # shared across datasets
task_type: swe
max_context_len: 65536
datasets:
swe_gym_train:
path: data/swe/swe_gym_train_minus_val100.jsonl
input_key: prompt
label_key: instance_id
retool_math:
path: data/retool_math.jsonl
task_type: retool # overrides the default
max_context_len: 32768
Structural fields
path, input_key, label_key, metadata_key, tool_key, task_type — what loads and where it routes.
Generation overrides
temperature, top_p, top_k, max_response_len, max_turns, max_context_len — None means "inherit".
Rollout overrides
n_samples_per_prompt, reward_key — per-dataset sampling and scoring.
EvalDatasetConfig
The eval-side twin: slime's config plus task_type, max_turns, max_context_len.
The 3-level cascade
Generation parameters resolve from coarsest to finest, the per-dataset value winning:
CLI args → EvaluatorSpec.default_stopping → DatasetEntry / EvalDatasetConfig
(coarsest) (finest, wins)
Resolved by the scheduler's _resolve_stopping (for StoppingCriteria) and the sampling-param builder; the Task Scheduler page documents it in full.
Routing: task_type is the evaluator_id
An EvaluatorSpec (scheduler/evaluator.py) is a blueprint: a handler (a BaseAgent), its resource model (resource / run_resource / eval_resource), an optional batch_size, and default_stopping. The scheduler selects one via batch.task_type, so a dataset's task_type must equal some spec's evaluator_id or the batch has nowhere to run.
Specs come from a build_evaluators(args) factory (list[EvaluatorSpec]), loaded via --evaluator-config-path and wired into a TaskScheduler by build_task_scheduler.
# rl_engine/examples/swe_agent/evaluator_config_dagger.py
def build_evaluators(args):
teacher = TeacherBackend(sglang_url=..., model_path=..., tools=SWE_TOOL_SPECS)
handler = DAggerSWEAgent(teacher_backend=teacher, max_turns=100, ...)
return [EvaluatorSpec(
evaluator_id="swe", # == dataset task_type
handler=handler,
run_resource=Resource(cpu=4, memory_gb=16), # agent container
eval_resource=Resource(cpu=4, memory_gb=16), # verification container
default_stopping=StoppingCriteria(max_turns=100, max_context_len=65536),
)]
5 Trajectory Post-Processing Lanes
Once execute_rollout() returns raw trajectories, _get_rollout_data shapes them for the loss. The order is fixed — filter → route by algorithm → shape — with the lane chosen entirely by args.algorithm.
Filters
Two independent filters run before shaping, on the raw one-per-task trajectories:
| Filter | Env var | Keeps |
|---|---|---|
| Rejection sampling | STACX_REJECTION_SAMPLING | reward > 0. Modes: all / warmup_only (only while β hasn't started decaying) / none (default). |
| Finish-only | STACX_FINISH_ONLY_FILTER | termination_reason == "finish" — drops context_overflow, action_budget, loops, max_turns. The launch scripts default this on. |
They compose: rejection first, finish-only on the survivors. If a filter empties the batch, _get_rollout_data returns one masked dummy so logging never trips on an empty rollout.
The algorithm fork
_maybe_apply_per_token_capture is the switch: per-token-loss algorithms capture teacher logprobs and return immediately (no chunk expansion); everything else falls through to CE-only expansion.
args.algorithm | Lane | Shape |
|---|---|---|
on_policy_distillation (OPD) | _apply_distillation_capture | 1 trajectory → 1 packed sample; K3 reverse-KL on student action tokens. |
mixture_dagger_opd · mixture_aggrevate_opd | _apply_mixed_loss_dagger_opd_capture | 1 trajectory → 1 packed sample; per-token mixed CE/K3 via a sentinel. |
| otherwise (CE-only online-DAgger SFT) | _build_trajectory_sft_samples | 1 trajectory → M+1 chunked SFT sub-samples. |
Teacher force-decode capture (per-token lanes)
Both per-token lanes call capture_teacher_logprobs_for_samples (distill_capture.py): kept trajectories are force-decoded through the teacher SGLang server with top_logprobs_num=K, yielding the teacher's chosen-token logprob at each response position. That pins sample.teacher_log_probs and overwrites sample.loss_mask with the action mask, so the loss reduces only over student action tokens.
The mixed CE/K3 lane snapshots the agent's per-token is-teacher mask, then overwrites teacher_log_probs with a +1.0 sentinel at teacher positions. Real SGLang logprobs are always ≤ 0, so the kernel reads the sign — > 0 ⇒ CE, ≤ 0 ⇒ K3 — meaning β=1.0 is pure CE (≡ legacy online-DAgger) and β=0.0 pure K3 (≡ OPD). The loss kernel lives in rl_engine/trainer/specialization/mixed_loss_dagger.py — see Algorithm Recipes.
DAgger trajectory-SFT sub-samples (CE-only lane)
The chunk-expansion lane (_build_trajectory_sft_samples) is the older CE-only path: each trajectory with M student-executed turns becomes up to M+1 plain-CE SFT sub-samples, driven by the _student_cut_points metadata.
_subsample_kind | When | What it trains |
|---|---|---|
dagger_pair | one per valid student cut | prefix up to the student's action, with the student's action replaced by the teacher relabel tokens (all mask=1) plus any teacher actions in the preceding gap. |
trailing_chunk | teacher turns after the last student cut | the full trajectory, masked to only the trailing teacher actions. |
teacher_only_trajectory | no cut points (β=1.0, or a legacy .pt) | the whole trajectory verbatim — the one case where 1 trajectory = 1 sample. |
A per-trajectory buffer makes the drop atomic — if any sub-sample exceeds max_seq_len the whole trajectory is discarded (semantics pinned by tests/rollout/test_trajectory_sft_samples.py). A deterministic per-iter shuffle (STACX_SHUFFLE_SEED_BASE) de-clusters sibling chunks; optional dynamic GBS scaling (STACX_TASKS_PER_GRAD_STEP) holds tasks-per-step constant as chunks grow.
Termination taxonomy & per-iter summary
termination_summary.py classifies outcomes. classify_termination(reward, termination_reason) maps every trajectory into one of eight classes; write_summary aggregates a pass into a summary.json with per-dataset counts and a per-task list.
| Class | Meaning |
|---|---|
solved | reward == 1.0. |
finish_unresolved | agent called finish but the answer was wrong. |
loop | repeated_action_loop. |
ctx_overflow | context_overflow. |
budget | action_budget or max_turns. |
timeout | aborted on the agent window or a sandbox/request timeout (classify_abort → agent_timeout). |
infra | aborted on a sandbox/transport error, not a model outcome (infra_conn / infra_sandbox / infra_other). |
other | any unrecognized termination_reason. |
Analysis dumps
Three optional, best-effort dumps capture token-level data for offline analysis. All are atomic (tmp + os.replace) and swallow failures so they can never abort training:
- OPD discrepancy dump (
STACX_DISTILL_DUMP_DIR) — per-iter.ptwith the exact(teacher, student)logprob pair the K3 estimator consumes, plus theaction_mask. - Mixed-loss dump (
STACX_MIXED_LOSS_DAGGER_DUMP_DIR) — the sentinel'dteacher_log_probsand the original is-teacher mask, so tools can split CE vs. K3 per position. - Per-iter student dump (
STACX_KL_DUMP_DIR, kl_eval_cache.py) —dump_samples_from_evalwrites one.ptper finished eval task (tokens, loss_mask, rollout logprobs, reward) plus amanifest.json; armed by default in the launch scripts.
6 Rollout-Layer Env Knobs
The STACX_* variables read inside rollout_manager.py and scheduler/task_scheduler.py — the rollout-layer slice of the full catalog (with launch-script defaults) in scripts/train/swe/ENV_REFERENCE.md.
Data path & filters
| Env var | Default | Effect |
|---|---|---|
STACX_FINISH_ONLY_FILTER | 0 (scripts set 1) | Keep only termination_reason == "finish". |
STACX_REJECTION_SAMPLING | none | Keep reward > 0: all / warmup_only / none. |
STACX_SHUFFLE_SEED | 0 | Per-experiment dataset shuffle seed in MultiDataSource. |
STACX_SHUFFLE_SEED_BASE | 42 | Base for the per-iter sub-sample shuffle (+ rollout_id). |
STACX_TASKS_PER_GRAD_STEP | unset | Dynamic GBS: scale global_batch_size to hold tasks/step constant as chunks/trajectory grows. |
STACX_REUSE_ROLLOUT · STACX_FORCE_FRESH_ROLLOUT | 0 | Load the saved trajectory .pt (multi-epoch) vs. force a fresh rollout every iter (true iterative DAgger). |
STACX_SAVE_PER_ITER_PT | 1 | Also write a per-iter trajectory_sft_samples_iter_NNN.pt snapshot. |
STACX_ROOT · DAGGER_OUTPUT_DIR | repo root · /tmp/dagger_data | Dataset-path anchor · trajectory .pt + JSON debug dumps. |
Teacher force-decode capture
| Env var | Default | Effect |
|---|---|---|
TEACHER_SGLANG_URL | required (OPD/mixed) | Teacher SGLang server for force-decoding student action tokens. |
STACX_DISTILL_TOPK | 20 | Top-K logprobs captured per token (K3 uses the chosen logprob). |
STACX_DISTILL_TEACHER_CONCURRENCY | 4 | Concurrent teacher force-decode requests. |
STACX_DISTILL_TEACHER_RETRIES | 5 | Retry attempts per force-decode request. |
STACX_DISTILL_TEACHER_BASE_DELAY | 2.0 | Exponential-backoff base delay (seconds). |
Analysis dumps
| Env var | Default | Effect |
|---|---|---|
STACX_DISTILL_DUMP_DIR | empty = off | OPD per-iter teacher↔student discrepancy dump. |
STACX_MIXED_LOSS_DAGGER_DUMP_DIR | empty = off | Mixed-loss per-iter dump (sentinel + is-teacher mask). |
STACX_KL_DUMP_DIR | empty; scripts arm it | Per-iter student eval-trajectory dump. |
STACX_KL_DUMP_DATASET · STACX_KL_DUMP_FORCE_LABEL | unset | Restrict the student dump to one eval dataset · override its label. |
Do not set these by hand
STACX_ROLLOUT_ID, STACX_DAGGER_BETA, and STACX_DAGGER_TAIL_T are written per-iter by scheduler/task_scheduler.py from the DAgger schedule. Setting them manually is overwritten and can corrupt the schedule — see ENV_REFERENCE §C.
7 Where To Go Next
Task Scheduler
rollout-scheduler.html — batching strategies, the PlacementPool allocator, the batch lifecycle, task state machine, and the multi-dataset param cascade.
Inference Backend
rollout-inference.html — SGLang serving, weight sync (inject vs. exchange), the onload/offload lifecycle, and CUDA-graph / KV-cache memory management.
Sandbox Management
env-sandbox.html and environment.html — the ROCK sandbox lifecycle behind RockSandbox that every episode steps against.
Algorithm Recipes
recipes.html — how the CE/K3 masks and sentinels from §5 become the mixed-loss DAgger, AggreVaTe, OPD, GRPO, and iter-SFT objectives.