Backend protocol

One Protocol, two modes: token-in/token-out SGLang for the student policy, text-level API clients for eval. §1–2.

Teacher force-decode

A frozen Qwen3-Coder-30B scores every student token to feed the K3 reverse-KL distillation loss. §3.

Recording proxy

An OpenAI-compatible shim that turns black-box scaffolds — OpenHands, Terminus-2 — into token-level training data. §4–5.

Ground truth lives under rl_engine/rollout/backends/, proxy/, and agents/. Related pages are linked in Quick Reference (§7).

1 The Backend Abstraction

Every LLM call in a rollout goes through a backend — an object turning a prompt into a completion, defined as a structural Protocol (rl_engine/rollout/backends/base.py), not a base class. Two modes coexist:

Token-level (SGLang)

Accepts raw input_ids, returns token_ids + per-token logprobs — the exact tokens the policy emitted with their sampling logprobs, which is what RL training needs. supports_token_ids = True.

Text-level (API)

Accepts OpenAI messages + tools, returns text and optional tool_calls. No token IDs or logprobs — eval only, never training. supports_token_ids = False.

The agent loop reads supports_token_ids to pick the input type and whether to record tokens. Both return one dataclass, GenerationResult:

# rl_engine/rollout/backends/base.py
@dataclass
class GenerationResult:
    text: str
    token_ids: list[int] | None = None      # SGLang only
    logprobs: list[float] | None = None    # SGLang only
    tool_calls: list[dict] | None = None    # API structured calls
    finish_reason: str = "stop"              # stop | length | abort | tool_calls | error:*
    usage: dict[str, int] | None = None  # API token accounting

class ModelBackend(Protocol):
    async def generate(self, *, input_ids=None, messages=None,
                       tools=None, sampling_params) -> GenerationResult: ...
    def tokenize(self, text) -> list[int]: ...   # API backends raise NotImplementedError
    def decode(self, token_ids) -> str: ...

The backends directory

FileExportsRole
base.pyModelBackend, GenerationResultThe Protocol + result dataclass. No logic.
sglang.pySGLangBackendToken-level /generate for the student policy (§2).
teacher.pyTeacherBackendWraps an SGLang server hosting the frozen teacher (§3).
openai_chat.pyOpenAIChatBackendText-level OpenAI-compatible client (Gemini, GPT, or a chat-endpoint SGLang) — eval only, with RPM/TPM/concurrency rate-limiting and 429-aware backoff.
token_tracker.pyTokenTrackerPer-trajectory aligned arrays: tokens, logprobs, loss_mask, obs_boundaries (§4).
dagger_tracker.pyDAggerTrackerPer-turn (context, target) training pairs for the DAgger relabel path.
api_pricing.pyMODEL_PRICINGUSD-per-million-token table for API cost accounting.

2 The SGLang Student Backend

SGLangBackend (backends/sglang.py) is the student policy's generation path — token-in / token-out. It reads the exact sampled token IDs and their logprobs from SGLang's native /generate: no re-tokenization, no chat-template round-trip, so the tokens the model emitted are the tokens training sees.

# SGLangBackend.generate — the request
url = f"{self._sglang_url}/generate"
payload = {
    "input_ids": input_ids,
    "sampling_params": sampling_params,
    "return_logprob": True,
}
output = await self._post_fn(url, payload)

# finish_reason == "abort" -> empty result (SGLang killed the request)
# else read meta_info["output_token_logprobs"] as (logprob, token_id) pairs
token_ids = [item[1] for item in meta["output_token_logprobs"]]
logprobs  = [item[0] for item in meta["output_token_logprobs"]]

A post_fn argument overrides the HTTP transport: everything uses slime's pooled post except the recording proxy, which injects its own loop-local httpx client (§4).

Where the URL comes from

The backend is built lazily inside the in-house SWE agent (swe_agent.py) from context.sglang_url, which the Evaluator sets to http://{sglang_router_ip}:{sglang_router_port} (scheduler/evaluator.py). Those router args come from two places:

  • Colocated training — slime stands up its own SGLang router alongside the Megatron workers and sets the args automatically. Nothing to configure.
  • External / evaleval.sh passes --rollout-external --rollout-external-engine-addrs plus explicit --sglang-router-ip/--sglang-router-port, from ROLLOUT_EXTERNAL_ENGINE_ADDRS (default 0.0.0.0:9001); the engine is a standalone server (§6).

Weight sync between iterations

In colocated training the SGLang engines share GPUs with the trainer, so updated weights must reach them before the next rollout. The main loop calls trainer.update_weights() before training and after each iteration (rl_engine/train.py), fanning out only to policy workers (roles with sync_to_rollout). Colocated runs hand off via CPU shared memory (dodging CUDA-IPC failures under Ray fractional-GPU placement), otherwise NCCL broadcast; trainer-side mechanics are on the Backends page.

Serving knobs

KnobDefaultWiringEffect
SGLANG_MEM_FRACTION0.55 train · 0.7 (4B) · 0.5 (8B)--sglang-mem-fraction-staticStatic KV-cache fraction of remaining VRAM. Lower it to leave room for Megatron activations + optimizer state in colocated mode.
MAX_CONTEXT_LEN65536 (train.sh export)agent context capPer-trajectory token budget for the agent loop.
SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN1SGLang-nativeLets the engine serve a context longer than the checkpoint's declared max_position_embeddings.
CONTEXT_LEN262144--context-length (serve_sglang.sh)Context window of the standalone eval server.
TOOL_CALL_PARSERqwen3_coder--tool-call-parserSGLang server-side tool-call parser; use qwen25 for Hermes-format models such as the 4B student.

The installed-scaffold factory (evaluator_factory.py) caps agent context at a fixed 131072 rather than reading MAX_CONTEXT_LEN — that env var applies only to the in-house SWE-agent path.

3 The Teacher Backend

TeacherBackend (backends/teacher.py) wraps an external SGLang server hosting the frozen Qwen3-Coder-30B teacher. Because the teacher speaks a different chat template and tool syntax than the student, it loads its own tokenizer (CPU-only) and resolves its own tool-call format. It never acts in the environment, and any failure returns None without touching the student's trajectory. It plays two roles.

Role A — per-turn action labelling (DAgger / AggreVaTe rollin)

When a turn is teacher-executed, label_turn(history, task_prompt, system_prompt) renders a prompt in the teacher's format (include_reasoning=True, so it sees its own prior chain-of-thought) and parses the reply into a CanonicalAction; the lower-level generate_from_tokens(input_ids) takes a pre-built token sequence instead. The rollin protocols live in dagger_agent.py — see Algorithm Recipes.

Role B — post-rollout force-decode for the K3 loss

After a rollout is collected and finish-filtered, each kept trajectory is force-decoded by the teacher to recover its probability of every student-emitted token — the log p_t term the K3 reverse-KL needs. Orchestration is _apply_distillation_capture (rollout_manager.py), calling score_response_with_topk (distill_capture.py).

Teacher force-decode path for the K3 loss Kept trajectories finish-only filter prompt + response ids student log p_s + action_mask distill_capture score_response_with_topk greedy · max_new_tokens=1 Semaphore · retries + backoff Teacher SGLang frozen Qwen3-Coder-30B TP=4 · GPUs 0-3 TEACHER_SGLANG_URL :30055 per traj. /generate return_logprob, top-K Teacher response meta_info input_token_logprobs → log p_t input_top_logprobs → top-K K3 loss inputs r = log p_t − log p_s loss = exp(r) − r − 1 summed over action_mask Trainer mixed CE / K3 mixed_loss_dagger.py chosen + top-K per-token r_t

Teacher force-decode. Kept trajectories flow right to the frozen teacher, whose per-token chosen + top-K logprobs return along the bottom as K3 loss inputs.

The payload force-decodes the exact response — one greedy step over prompt_ids + response_ids with logprobs on:

# distill_capture.py — score_response_with_topk (payload)
sampling_params = {
    "max_new_tokens": 1, "temperature": 0.0, "top_p": 1.0,
    "top_k": -1, "no_stop_trim": True,
}
payload = {
    "input_ids": prompt_ids + response_ids,   # force-decode the exact response
    "sampling_params": sampling_params,
    "return_logprob": True,
    "logprob_start_len": start_len,           # = prompt_len - 1
    "top_logprobs_num": K,                    # STACX_DISTILL_TOPK (default 20)
}

A persistent teacher failure is not swallowed — it propagates, failing the run loudly rather than silently emitting empty logprobs. The captured fields are pinned onto each Sample (teacher_log_probs, teacher_topk_indices, teacher_topk_logprobs), and loss_mask is overwritten with the action mask so the loss sees exactly the student-emitted tokens.

Greedy force-decode ≠ teacher sampling

The force-decode is hard-coded greedy (temperature=0.0, top_p=1.0): it measures the teacher's probability of the student's tokens, so it must not sample. TEACHER_TEMPERATURE / TEACHER_TOP_P apply only to Role A, not here.

Teacher & distillation knobs

Env varDefaultRead atEffect
TEACHER_SGLANG_URLrequiredrollout_manager.pyBase URL of the teacher SGLang server. OPD / mixed runs raise if empty.
TEACHER_PORT30055launch scriptsBuilds TEACHER_SGLANG_URL=http://127.0.0.1:${TEACHER_PORT}.
TEACHER_TEMPERATURE0.0 scripts 0.7teacher.pySampling temperature for Role-A teacher actions.
TEACHER_TOP_P1.0 scripts 0.9teacher.pySampling top-p for Role-A teacher actions.
TEACHER_FORMAT"" (auto)teacher.pyForce the teacher tool-call format when auto-detection misfires.
STACX_DISTILL_TOPK20rollout_manager.py · distill.pytop_logprobs_num for the force-decode; must match the train-time K.
STACX_DISTILL_TEACHER_CONCURRENCY4rollout_manager.pyMax concurrent force-decode requests (Semaphore).
STACX_DISTILL_TEACHER_RETRIES · _BASE_DELAY5 · 2.0distill_capture.pyRetry attempts (total = retries + 1) and base seconds for base · 2^attempt backoff.
STACX_DISTILL_KL_MODEk3_sampledistill.pyRKL estimator: k3_sample (any TP, default) or topk (full top-K RKL, TP=1 only).
STACX_DISTILL_DUMP_DIR"" (off)rollout_manager.pyIf set, dumps per-iter teacher↔student token discrepancy data.
STACX_DISTILL_SKIP_TEACHER_GENERATE0dagger_agent.pySkip the per-turn teacher generation under pure OPD (β=0), where it is wasted.

4 The Recording Proxy

The in-house SWE agent emits tokens natively; the black-box installed scaffolds — OpenHands, Terminus-2 — cannot. The RecordingProxy (proxy/recording_proxy.py) bridges the gap: an OpenAI-compatible façade between scaffold and SGLang that intercepts /v1/chat/completions and reconstructs a token-level trajectory without touching the scaffold. One proxy per task, on an auto-assigned port, torn down at task end.

Context construction. The proxy keeps one running token series (a TokenTracker) across a task's chat calls; the series fed to the model is the stored trajectory, so the training sequence equals the conditioning context at every turn.

Token-capture data path Scaffold OpenHands · Terminus-2 OpenAI /v1/chat completions client Recording proxy recording_proxy.py · one per task Append-only guard _sync_series · _sig Incremental tokenizer tokenize_messages_increment TokenTracker series tokens · logprobs · loss_mask SGLang server /generate token-in / token-out POST /v1/chat ChatCompletion /generate input_ids tokens + logprobs get_result_fields() Training sample (tokens, loss_mask, logprobs) 1 packed sample / trajectory Trainer GRPO / SFT / K3 batch capture path fail-safe: passthrough (capture off)

Token capture. The proxy builds one token series across a task's chat calls.

The per-call cycle

  1. Sync the series_sync_series finds the messages appended since the last turn, skipping the assistant turns the scaffold echoes back (already in the series).
  2. Tokenize the increment — new observation/tool messages go in mid-conversation via tokenize_messages_increment with loss_mask = 0; the first call renders the whole prompt.
  3. Generate/generate runs on the exact series; the returned token IDs + logprobs are appended verbatim with loss_mask = 1.
  4. Reply — those tokens are decoded back into an OpenAI ChatCompletion (with tool_calls) so the scaffold acts as it would have.

The append-only guard

The scheme assumes an append-only conversation. If a scaffold rewrites or summarizes its history, the stored series no longer matches — so _sync_series checks a per-message signature (_sig: role + content shape) against everything consumed so far. A shrunk or rewritten prefix raises _AppendOnlyViolation: capture is disabled and the request passes through, so the run completes but emits no token data. Semantics are pinned by test_recording_proxy_guard.py:

# the guard, distilled from the tests
_sig(m)  ->  (role, content_shape)         # role or content change is detected

pure append (messages grow)      ->  OK
history shrank  (len drops)      ->  raise _AppendOnlyViolation("shrank")
prefix rewritten (summary swap)  ->  raise _AppendOnlyViolation("rewritten at message i")

Other failures are equally defensive: an SGLang abort or any unexpected exception flips capture_valid off and passes through, while get_result_fields() returns None on an invalid or length-inconsistent series (logprobs, mask, and tokens must align) — dropping the trajectory rather than risking a corrupt sample. The result matches the native SWE-agent shape: one completion region of prompt_token_ids, completion_token_ids, logprobs, and a loss_mask that is 1 on generated tokens (append_generation) and 0 on observations (append_observation).

5 The Installed-Scaffold Path

InstalledAgent (agents/installed.py) runs a scaffold as a black-box CLI tool inside a ROCK container: it owns the container lifecycle, while a per-scaffold InstalledScaffold supplies the install/run commands and log/termination semantics — shell commands in, files out.

The runner

InstalledAgent.run() executes a fixed per-task sequence: start ROCK sandbox → optionally start recording proxy → install scaffold → run it → freeze benchmark artifact → run verifier → parse trajectory → capture logs → classify termination → tear down. The proxy interposition is the switch that turns eval into training:

# installed.py — _maybe_start_proxy (train vs eval, one branch)
if not self.capture_tokens:
    return None                       # eval: scaffold talks to SGLang directly
proxy = RecordingProxy(sglang_url=..., tokenizer_path=hf_checkpoint, ...)
proxy.start()
context.proxy_url = proxy.url          # scaffold is pointed here instead

The scaffold's base URL is then context.proxy_url or context.sglang_url — the proxy when capture is on, SGLang directly when off (as in eval). After teardown, proxy.get_result_fields() pulls the token series onto the AgentResult.

OpenHands

A self-contained pip CLI (RUNTIME=local) that calls the LLM through LiteLLM's openai/ provider — so SGLang's native tool-call parser fires — and logs completions + a trajectory JSON under /logs/agent. Capture forces append-only via a NoOp condenser and AGENT_ENABLE_HISTORY_TRUNCATION=false.

Terminus-2

A Harbor terminal agent driving a tmux session: pinned Harbor source cloned into the container and run by a thin in-container runner; it too targets proxy_url or sglang_url. Summarization is on for eval, off (append-only) under capture.

Termination-reason classification

Every run gets a _termination_reason that drives the failure-class summary. The base classifier (InstalledScaffold.termination_reason) keys off the exit code; scaffolds refine the clean exit. OpenHands' (pinned by test_openhands_termination.py) applies this precedence, first match wins:

Precedence (first match wins)Reason
exit 124 (exec wall-clock kill)agent_timeout
exit 137 (OOM / SIGKILL) — external kill beats any markerinfra_other
a finish action in the trajectoryfinish
a terminal-error marker in the last 40 log linesmarker table ↓
steps ≥ max_iterationsmax_turns
any other nonzero exitinfra_other
clean exit, no signalother
Log-tail phrase (case-insensitive)Reason
reached maximum iterationmax_turns
stuck in a looprepeated_action_loop
exceeds the model's maximum context lengthcontext_overflow
contextwindowexceededcontext_overflow

Terminus-2's is simpler: a clean exit with a mark_task_complete call is finish, else max_turns if the step budget was hit, else finish; nonzero exits defer to the base classes.

Capture vs. plain evaluators

Which variant runs is a build-function choice. SkyRL (skyrl/evaluator_config.py) exposes four: build_evaluators_openhands, …_openhands_capture, …_terminus2, …_terminus2_capture. The _capture variants pass capture_tokens=True — the master switch that turns the proxy on and forces append-only history (raising if APPEND_ONLY=off). The rest leave the proxy off and allow summarization: pure eval.

6 Serving Topologies

The same backends wire up three ways:

A · Eval-only (external server)

serve_sglang.sh starts a standalone engine (python -m sglang.launch_server: --tp-size, --tool-call-parser, --disable-radix-cache, --context-length 262144, port 9001). eval.sh connects with --rollout-external and runs --num-rollout 0 --debug-rollout-only, writing an eval JSONL. No trainer, no teacher.

B · Co-located training (in-house SWE agent)

The dagger / aggrevate / OPD recipes run on one 8-GPU node: the frozen teacher (Qwen3-Coder-30B, TP=4) on GPUs 0-3 port 30055, the student Megatron workers and their SGLang engines on GPUs 4-7 under Ray --colocate. Weights sync to the engines each iteration (§2); the teacher is queried per-turn (Role A) and force-decoded post-rollout (Role B). ROCK admin serves sandboxes at 127.0.0.1:8080.

C · Installed-scaffold training (recording proxy in the loop)

train.sh runs a Ray job: the training rollout uses the _capture evaluator (proxy on, append-only) for BENCH, eval uses the plain evaluator for a separate EVAL_BENCH. Each task's own RecordingProxy fronts the shared student engine, turning black-box calls into trainable token series.

TopologyStudent engineTeacherProxyKey ports
A · Eval-onlyexternal serve_sglang.shoff9001
B · Colocated trainRay + Megatron, GPUs 4-7SGLang GPUs 0-3off (native tokens)30055 · 8080
C · Installed-scaffold trainRay, colocatedoptional (OPD)per-task, onauto · 8080

7 Quick Reference

SymbolLocationResponsibility
ModelBackend · GenerationResultbackends/base.pyBackend protocol + result dataclass.
SGLangBackendbackends/sglang.pyToken-level /generate; generate, tokenize, decode.
TeacherBackendbackends/teacher.pylabel_turn, generate_from_tokens — frozen teacher relabel.
OpenAIChatBackendbackends/openai_chat.pyText-level API client (eval only), rate-limited.
TokenTrackerbackends/token_tracker.pyfrom_prompt, append_generation, append_observation, to_result_fields.
score_response_with_topkrollout/distill_capture.pyTeacher force-decode → chosen + top-K logprobs.
_apply_distillation_capturerollout/rollout_manager.pyOrchestrates force-decode, pins fields onto samples.
RecordingProxyrollout/proxy/recording_proxy.py_sync_series, _handle_chat_completion, get_result_fields.
InstalledAgent · InstalledScaffoldrollout/agents/installed.pyBlack-box container runner + scaffold interface.
OpenHands · Terminus2rollout/agents/installed_scaffolds/Per-scaffold install/run + termination classifier.

Related pages: Rollout Architecture · Task Scheduler · Algorithm Recipes · Sandbox Management · Trainer Backends.