Skip to content

feat(orchestrator)!: align multi-agent types - #3183

Closed
mikasenghaas wants to merge 65 commits into
mainfrom
feat/first-class-episode-failures
Closed

feat(orchestrator)!: align multi-agent types#3183
mikasenghaas wants to merge 65 commits into
mainfrom
feat/first-class-episode-failures

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #3165. The orchestrator passes episodes end to end — the env's own vf.Episode,
extended only with what prime-rl genuinely adds — instead of flattening them into loose traces at
the boundary and rebuilding the grouping downstream. Three changes, each one falling out of the last.

1. An episode that produced nothing needs no stand-in trace

A cancellation, or a task that raised before reaching the env, was reported as a fabricated
Rollout carrying a fake error
. That trace has no real agent, so it took AgentInfo's default
name, and once #3165 keyed metrics by agent those failures landed under a phantom agent subtree —
in a proposer-solver env, cancellations inflated a seat nobody ran. (Reported by bugbot on #3165.)

verifiers already has the shape: run_episode records the reason on episode.errors and returns
the episode with ok false. Prime-rl's own outcomes are minted the same way, so one vocabulary
covers every cause. envs.py stops unwrapping the episode, metrics.py stops rebuilding it, and
vf's own by_agent / num_turns / num_*_tokens replace the hand-rolled versions.

traces.jsonl now stores one episode per line — what verifiers writes and what its
read_episodes expects; prime-rl was writing the legacy bare-trace form. A traceless episode gets a
row too, with the reason on errors.

2. prime-rl stops extending the episode and the trace

Everything the orchestrator adds to a dispatch now has a place on verifiers' own types
(PrimeIntellect-ai/verifiers#2252): the env it ran (env.name) and the run it belongs to (run
an id, plus a metadata saying whether the run trains on the episode or measures itself with it,
and carrying the step and policy span that go with that). The comparison group is prime-rl's alone —
verifiers has no notion of one — so it rides in episode.info, read through one accessor.

Episode = vf.WireEpisode      # alias — nothing added
Rollout = vf.Trace            # alias — nothing added

class TrainRollout(vf.Trace[DataT]):
    env_name, samples, is_filtered, filter_results

TrainEpisode, EvalEpisode, Episode.KIND and the Rollout base are gone; what were methods are
free functions over the vf types. TrainRollout is the one extension left, because trainer-bound
state has nowhere else to live — samples in particular cannot go in trace.info, which
serializes.

The main loop routes on run.metadata and fills in a train episode's step, the one fact the
dispatcher can't know: it is the batch window collecting when the episode lands, not when it was
dispatched. An eval's is known at dispatch, so EvalMetadata.step is required.

Staleness is derived, not stored. It used to be written twice — the dispatcher counted weight
updates per in-flight episode, then the main loop discarded that and recomputed
(step - 1) - policy_version at ship. The dispatcher now records the policy span generation
covered, and every reading derives from it: the cancel check subtracts against the live version, the
in-flight gauges do the same, and off_policy_steps is a property of each metadata: versions behind
what is training for an episode trained on, and what drifted under it for an eval, which is the only
sense in which an eval can be off-policy. A frozen sampler records no span at all, so its staleness
reads None instead of a zero that looks fresh.

InflightRollout becomes InflightEpisode, and stamp() is the single place a dispatch's facts
become an episode's.

3. Algorithms score episodes; credit lives on the graph's nodes

score_group / finalize_group take the group's Episodes, so an algorithm can compare within
an episode as well as across them — hierarchical_grpo keys its solver baselines off episode.id
instead of a foreign key copied onto every trace. group_rollouts(group) flattens for GRPO / MaxRL /
RAE, which only compare across.

Per-agent metrics are built from episodes too (narrow, keyed by vf.Episode.by_agent), so
solve_rates and pass_at_k bucket on the episode's group — and Rollout.group_id is gone. The
metrics stay flat over traces; the episode is only what they are built from.

Credit moves onto MessageNode (PrimeIntellect-ai/verifiers#2245, merged and pinned here).
TrainRollout.advantages becomes a derived read, assign_advantages(value) writes each node's
trainable tokens, and stamp_advantages copies branch.advantages onto the sample built from that
branch rather than slicing one flat stream back apart by offset. Unassigned stays distinct from
assigned-zero all the way to the trainer.

Breaking

Types

  • Episode and Rollout are aliases for vf.WireEpisode / vf.Trace. TrainEpisode,
    EvalEpisode and Episode.KIND are gone; read the path off run.metadata.type, the step off
    run.metadata.step, the env off env.name, the group off info["group_id"], staleness off
    run.metadata.off_policy_steps.
  • What were Episode methods are free functions in orchestrator.types: narrow, rollouts_of,
    group_id_of, env_name_of, run_of.
  • Rollout no longer carries kind, policy_version, off_policy_steps, eval_step, group_id
    or episode_id. env_name, samples, is_filtered and filter_results are on TrainRollout.
  • Monitor.log_samples / log_eval_samples take episodes, not traces. The wandb sample table gains
    agent and branch_idx columns, one branch per row.
  • TrainRollout.advantages is read-only (derived from the nodes) and assign_advantages takes a
    scalar only — the full-length per-token list is gone. No shipped algorithm used it.
  • InflightRolloutInflightEpisode (rollout_countepisodes_owed); stamp() takes a
    run_id, and RolloutDispatcher a matching ctor argument.

Algorithms

  • Algorithm.score_group / finalize_group take list[Episode], not list[TrainRollout]. A custom
    algorithm comparing across the whole cohort wraps its body in group_rollouts(group).
  • TraceMetrics(...) and pass_at_k(...) take episodes.

Metrics / on-disk

  • traces.jsonl rows are episodes, not traces. verifiers' read_episodes reads both.
  • {scope}/{subset}/<agent>/has_error/mean no longer counts cancellations or task failures — an
    episode nobody ran belongs to no seat. Those report under
    dispatcher/{cancelled,errored}/{train,eval}.

Verification

  • uv run pytest tests/unit — 481 passed. Two exclusions on this box, both pre-existing and
    unrelated: tests/unit/train/models (GPU crash) and test_qwen3_vl_e2e (stale fixture, fix: token_id-formatted logprob tokens in the qwen3-vl fake engine #3161).
  • Four live reverse-text runs on 2 GPUs:
    • credit — 12 steps, reward 0.17 → 0.75, Trainable 128/128 throughout, so node-assigned
      credit reaches the trainer.

    • episode collapse — train+eval, evals at steps 2/4/6; traces.jsonl rows carry
      run={'type': 'train', 'step': <batch window>} / {'type': 'eval', 'step': <eval epoch>} with
      the dispatch facts in info.

    • metrics reworksolved_*, avg@k and the per-agent subtree land unchanged on both
      subsets.

    • no subclasses — a 6-step run with eval. Both kinds of episode carry the same run id and are
      told apart by their metadata, and everything prime-rl used to keep on a subclass is on the
      record:

      // step_6/train/all/traces.jsonl
      "run":  {"type": "train", "id": "2bfeba20...",
               "metadata": {"type": "train", "step": 6, "policy": {"start": 3, "end": 3}}},
      "env":  {"id": "reverse-text-v1", "name": "reverse-text"},
      "info": {"group_id": "ec69996d-..."}
      
      // step_6/eval/all/traces.jsonl — same run id, different metadata
      "run":  {"type": "train", "id": "2bfeba20...",
               "metadata": {"type": "eval", "step": 6, "policy": {"start": 4, "end": 4}}}

Two latent bugs surfaced on the way and are fixed here: both sinks read a group's env and step off
group[0] — a trace — which IndexErrors when a whole group is cancelled and produces none. No
test covered that path.

Follow-ups

  • Depends on feat(v1): run metadata on episode verifiers#2252; deps/verifiers is pinned to that branch and needs a
    re-pin to main once it merges.
  • Up to date with main as of feat!: standalone env servers #3162 (standalone env servers), verified with a live run.
  • TrainRollout is the remaining extension. Retiring it means moving samples / is_filtered /
    filter_results into a sink-owned table keyed by trace id, which metrics would then have to
    carry — a subclass traded for threading, so I would keep it.
  • Filters are untouched here on purpose. Splitting degeneracy detection (a measurement of every
    trace) from the drop policy (a decision) is a change of its own — it fixes apply_filters stopping
    at the first hit, which makes a monitoring rate depend on filter order — and it goes in a
    follow-up rather than riding along with the episode work.
  • The sinks, the ship path and the eval summary have no unit coverage. Four runtime
    AttributeErrors during this work reached a live run through a green suite, each one a read of a
    field the subclass used to have. Scripted sweeps now check every episode and run attribute read
    against the vf surface, but that is a stopgap for real tests.
  • dropped/all/<name>/rate is only emitted in steps where that reason fired, so the per-reason
    series is sparse. Pre-existing under the old key.

🤖 Generated with Claude Code

mikasenghaas and others added 27 commits July 30, 2026 19:14
Adapt to verifiers#2187 (Episode derived aggregates): episode.last_error
rename, submodule bump. Mirror the episode/trace hierarchy in the wandb
layout: count metrics read at the episode level (per-episode sums), and
a new {scope}/{subset}/<agent>/<metric>/<stat> level reports trace-level
metrics per agent, averaging in-episode fan-outs first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s -> TraceMetrics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation span renamed agent

Env level now carries only episode-level facts (count sums, pipeline
rates, eval scores); reward, truncation, errors, stop conditions, timing,
custom metrics and solve rates move under {scope}/{subset}/<agent>/.
Adapts to the vf span rename (Timing.agent, AgentSpan, split_agent_time)
and re-pins deps/verifiers. Overview reward/error/truncation panels match
the per-agent keys by regex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…udge debug config

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
verifiers f646beb37 -> f6e420b99 (0.2.2.dev66, 17 commits) and
research-environments 6a2dee6fc -> ccb375ce5 (main + the scicode-v1
pin relax from research-environments#740).

Adaptations to the verifiers changes:
- Episode.error is now Episode.errors + a last_error property (vf#2187)
- verifiers.v1.loaders / verifiers.v1.push moved into
  verifiers.v1.utils.{loaders,platform} (vf#2204)
- verifiers floors bumped to 0.2.2.dev66 (root and prime-rl-configs)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ccb375ce5 -> 9a57c713c: research-environments#740 refreshed on current
main, picking up the ViRL39K v1 taskset (RE#689) and the taskset prompt
config fixes (RE#742). verifiers main is unchanged since f6e420b99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f6e420b99 -> 576506d66 (7 commits). vf#2218 builds one client per
rollout with a process-wide elastic renderer pool: TrainClientConfig
drops pool_size for a multiplex knob, and the config module moved from
clients.config to configs.client. The orchestrator config follows suit
(pool_size -> multiplex, None = client default). Floors to dev73.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rchestrator.multiplex

deps/research-environments -> 21a284618 (research-environments#740 on
main). The orchestrator-level renderer knob (pool_size, briefly
multiplex) is removed instead of renamed: clients resolve inside env-
server workers, whose concurrency is already bounded per env by
serve.pool.multiplex (default 128, below the client-side renderer
default of 256), so each worker warms one renderer and the elastic pool
grows on demand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etrics

Takes #3172's newer verifiers pin (576506d66, contains the vf#2187 merge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts main's timing kwarg name in the metrics test fixture; the timing
assertions stay on the agent subtree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
is_trainable / is_filtered / filters and avg@k / pass@k all score a
single trace, so they read per agent like every other trace-level
metric instead of pooling an episode's seats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
is_truncated / is_completed rode the per-episode mean path with the
distributions, so an uneven fan-out — what the effective subset leaves
whenever a sibling errors — reweighted them away from the plain
fraction their siblings and the docs promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One aggregation per level: the episode level sums an episode's traces,
the agent level takes each trace as a sample. Reward, avg@k and the
per-agent counts had been collapsing each episode's fan-out to a mean
first, which left them the odd ones out among the seat's own metrics and
made avg@k's k — a trace count — disagree with what it averaged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keeps #3182's unscored-as-zero coverage, rekeyed onto the agent subtree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dispatched episode that never produced a trace used to be reported as a
stand-in Rollout carrying a fake error. That trace had no real agent, so
it defaulted to the 'agent' seat and its failure landed under a phantom
subtree in a multi-agent env, inflating that seat's error rate while the
real seats looked clean.

EpisodeResult now carries an episode's scheduling facts plus either its
traces or an EpisodeFailure, so the outcome is representable without
inventing a rollout, and failures are counted at the episode level where
they belong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng it

envs.py used to read .traces/.id/.ok/.last_error off the episode and throw
the envelope away, so metrics.py regrouped the flattened traces on
episode_id and hand-rolled by_agent plus the token/turn sums that
vf.Episode already exposes.

The episode now rides through: Env.run returns it with its traces
re-typed, EpisodeResult carries it, the sinks bucket episodes rather than
loose rollouts, and the metric containers narrow an episode to its
surviving traces for a subset view. The count metrics and by_agent are
now reads off vf.Episode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikasenghaas
mikasenghaas changed the base branch from feat/episode-agent-metrics to main August 3, 2026 23:04
mikasenghaas and others added 2 commits August 3, 2026 23:06
main carries #3165 as a squash; this branch was cut from its pre-squash
head, so the conflicts are the same content seen twice and resolve to
ours (which already has it, plus this PR's work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… traceless episodes

EmptyEpisode was never a failure mode of its own — vf's run_episode
already raises when run() mints no trace, records it on episode.errors
and returns the episode — and the code that reported it threw that real
error away for a generic string.

So there is one shape for every failure that has no trace to ride: an
episode with no traces and the reason on errors. EpisodeFailure and
EpisodeResult both go; prl's Episode subclasses vf's and adds only the
six scheduling facts prime-rl actually contributes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mikasenghaas and others added 3 commits August 4, 2026 18:37
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sampled node shared by several branches is trainable only in the first
one; the branch view still spreads its credit everywhere it appears, so
zero the positions the sample does not train on — the layout the trainer
has always been given.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The train/eval split was a type distinction the producer already knows:
the dispatcher is what decides which path an episode is on. It now writes
that into vf's own RunInfo when the episode lands, so TrainEpisode,
EvalEpisode and the KIND ClassVar all collapse into one Episode and the
main loop reads run.type instead of asking isinstance. An eval episode's
step rides in run.step; a train one's is filled by the loop that knows
which batch window is collecting.

Also re-pins deps/verifiers to vf#2245 as merged on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikasenghaas mikasenghaas changed the title feat(orchestrator)!: make the episode the unit the orchestrator passes around feat(orchestrator)!: align multi-agent types Aug 4, 2026
mikasenghaas and others added 3 commits August 4, 2026 18:55
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TraceMetrics took a loose trace list, so a trace had to carry the example
it answered for solve_rates and pass@k to bucket by. It now takes the
episodes narrowed to one agent (Episode.narrow, keyed by the agent names
vf.Episode.by_agent reports), which is where group_id already lives —
so the field comes off the trace entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gibberish and repetition were filters with an enforce flag, which meant
their monitoring numbers were censored: apply_filters stopped at the
first hit, so a rollout flagged as gibberish was never measured for
repetition. They are now detectors — pure per-trace measurements that all
run on every trace and report per agent.

Zero-advantage stops being a plugin. It is not a property of the
generation (it is only knowable after the group scores, and it is what
is_trainable already means), so it becomes the drop policy's built-in
default. Rollouts that were never scored stay: opd/opsd assign no credit
and train through reference KL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mikasenghaas and others added 9 commits August 4, 2026 21:28
Everything the orchestrator adds to a dispatch now has a place on vf's
own episode: the env it ran (env.name), the group it was planned in
(group), and the run it belongs to — which on the training path carries
the policy version and how stale it got, and tells train from online eval
by kind. Episode is an alias for vf.WireEpisode, and what were methods
are free functions over it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
log_samples/log_eval_samples took loose traces, which is why a trace had
to carry the episode it came from and the env it ran in. They take
episodes now: the platform monitor reads the episode id off the episode,
and the wandb table gains agent and branch_idx columns so a multi-agent
episode reads as its seats, one branch per row.

Rollout is an alias for vf.Trace. TrainRollout is what remains — the one
place prime-rl extends a verifiers type, for trainer-bound state that has
nowhere else to live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Online eval moved onto the training run, but the sink still asserted on
EvalRunInfo. Nothing unit-tested the sink, so only a live run caught it —
covered now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sors

Five reads still went at the fields the subclass used to have, which a
pydantic model answers with AttributeError at runtime — in the sinks and
the ship path, none of which the unit suite covers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dispatcher counted weight updates per in-flight episode, then the
main loop threw that away and recomputed staleness at ship — two
mechanisms writing one field. The dispatcher now records the span
generation covered and every reading derives from it: the cancel check
subtracts against the live version, the in-flight gauges do the same, and
off_policy_steps is a property of the run.

Eval is measured the same way rather than pinned to 0, and a frozen
sampler records no span at all, so its staleness reads None instead of a
zero indistinguishable from fresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Only prime-rl writes episodes carrying training tensors, so only it needs
the exclusion; vf's own writer dumps the episode whole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mikasenghaas and others added 10 commits August 4, 2026 22:59
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Whether a trace is gibberish or stuck in a loop is a fact about it, not
something a run opts into computing: measuring it behind config made the
rate depend on what was configured. Both run on every trace with fixed
thresholds, and the only choice left is whether to act on one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three lost imports and one stale reference in the prime monitor, which
would have raised on the first sample upload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rollout aliased the bare generic while Episode aliased the wire form, and
TrainRollout resolved its agent config to the strict default — so the
traces prime-rl declared were a different specialization from the ones
WireEpisode carries. model_construct skipped validation, so nothing ever
said so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e.info

Verifiers has no notion of a group, so carrying one there was a field it
held for a single consumer. It rides in info, which is where an episode
takes a consumer's metadata, and it still lands on the saved record so a
row stays placeable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The detector/drop-policy split is a change of its own and does not need
to ride along with the episode work — it goes in a follow-up. filters.py,
its config, its tests and its docs are back to main; TrainRollout keeps
is_filtered and filter_results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikasenghaas

Copy link
Copy Markdown
Member Author

Closing in favour of a minimal rewrite — this branch accumulated several changes (filters, the trace-type collapse, group-in-info, accessor helpers) that belong in their own PRs. Superseded by a fresh PR scoped to the episode work alone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant