Skip to content

feat(v1): replay taskset — re-enter saved rollouts as continue/recheck tasks#1933

Draft
faresobeid wants to merge 11 commits into
mainfrom
feat/replay-taskset
Draft

feat(v1): replay taskset — re-enter saved rollouts as continue/recheck tasks#1933
faresobeid wants to merge 11 commits into
mainfrom
feat/replay-taskset

Conversation

@faresobeid

@faresobeid faresobeid commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What

A built-in v1 taskset, replay, that re-enters saved rollout records (Trace.to_record() JSONL lines) as fresh tasks — plus the small framework seams a wrapping taskset needs. This is the verifiers half of prime-rl's Continue/Recheck training (prime-rl PR to follow); everything is driven by env config, no orchestrator changes.

taskset = { id = "replay", records = "<glob>", mode = "continue" | "recheck", source = { id = "<original taskset>", ... } }

Each replayed task is the original typed task with its prompt swapped for the seed (model_copy), and every hook — tools, user, setup/finalize, score/score_group, stops, state — delegates to the source taskset loaded by id. New completions are therefore judged by the original env's own verifier, with its real toolkit.

Modes

  • continue, anchor = "compaction" (default): one task per recorded context restart. Detection is structural, not textual — a restart is a branch that forks off shared history on unsampled, system/user-only launch context before its first sampled node — so records from different harnesses (each with its own compaction wording/shape) mix freely. The canonical [system, user(summary)] restart lowers to a plain string prompt, so it runs under any harness, including agent CLIs like rlm (a fresh launch reproduces the post-compaction context exactly). A guard drops candidates equal to the task's own launch context (branch order follows leaf order, so an auxiliary same-system branch can sort first), and retokenization splits are excluded because their forked context carries assistant/tool copies.
  • continue, anchor = "tool-call": one deterministically-drawn resume point per rollout (Random(f"{seed}:{trace.id}")), right after a complete tool-result run (every tool_call id of the issuing assistant answered). Messages prompt → needs a message-seeding harness (default/null).
  • recheck: the attempt's final branch, truncation artifacts stripped (everything from the first partially-answered tool run; trailing unanswered user turns), plus a configurable verification turn. last_reply-style rewards score the revision — seeded context is never sampled.

Other recycling schemes subclass ReplayTaskset and override seeds(trace); record loading, delegation, and snapshot plumbing stay inherited.

Sandbox snapshots (seam only)

No runtime can capture/restore snapshots yet, but the contract is in place so producers can start recording: trace.info["snapshots"] maps node index → opaque ref, keyed at the node a resume point re-enters at. A record carrying any refs offers only snapshotted anchors (an unsnapshotted mid-trajectory resume would be unfaithful once state is captured), and setup restores via restore_snapshot(), which fails loudly until support lands (endgame documented there: promote the ref to a base Task field alongside image/workdir).

Framework seams (shared surface — the part to review closely)

  • Taskset.defines_tools / defines_user / needs_container / stops() / defines_group_rewards() / state_type() — instance-level seams replacing class-identity checks (type(ts).tools is not Taskset.tools) and direct discover_decorated calls in Environment.__init__, Rollout.run, EnvServer, Episode, and cli/validate. Defaults preserve today's behavior exactly; a wrapping taskset overrides them to report its source. Note defines_group_rewards is deliberately a method: hook discovery walks instances with inspect.getmembers, which evaluates properties — a discovery-running property recurses (pinned by a test).
  • Harness.write_message_prompt() + shared INITIAL_MESSAGES_FILE: a Messages prompt now ships to the default/null programs as a file in the runtime instead of the INITIAL_MESSAGES env var — replayed histories are exactly the long contexts that overflow MAX_ARG_STRLEN (~128 KiB per env string).

Operational details

  • ReplayConfig validation freezes records globs to the concrete matched files, so every pool worker (elastic pools spawn workers mid-run) builds the identical, positionally-indexed task list.
  • Mixed-env record files: lines are filtered by validating the task payload against the source's task type (cheap, before full-trace validation) and counted as skipped. The filter is structural — sources whose task type has no distinguishing required fields should use single-env files (documented).
  • max_seed_tokens drops over-long seeds at load (recorded token count when present, chars/4 estimate otherwise).

Verification

  • tests/v1/test_replay_taskset.py (18 tests): seed construction across compaction shapes (rlm shape, kept-task-message shape, multi-compaction), retokenization/subagent/aux-branch exclusions, complete-vs-partial tool runs, recheck cleanup, snapshot gating, config narrowing + dump→re-validate round trip (the pool-worker path), glob freezing, capability/state delegation against the echo fixtures.
  • Full local suite: tests/v1/ + tests/test_imports.py + tests/test_environment.py + tests/test_env_server.py → 87 passed (excluding the pre-existing wiki_search_compact.toml env-dependent failure, which fails identically on the base commit).
  • End-to-end smoke: synthesized records → load_tasks in all three modes → correct prompts/typed task fields; Environment construction under the rlm harness passes the capability gates.

🤖 Generated with Claude Code

Update: online self-replay (records are always followed)

replay can now follow a growing records source — the current run's own rollouts/step_*/train_rollouts.jsonl — instead of requiring a finished prior run. There is no freeze/knob — load_tasks is always incremental + append-only (numeric-aware step ordering, only new files parsed, indices never reassigned); the env server refreshes through a new Taskset.reload_tasks() seam on info and on out-of-range task indices, so pool workers refreshing at different times stay index-aligned. The replay env's own output (continue:/recheck: task names) is skipped so seeds don't compound. Covered by a follow-mode test (append across reloads, step_9/step_10 ordering, self-replay guard).

Update: max_anchors + source-reward filters + per-env records

max_anchors: int | None = 1 on continue/tool-call draws that many resume points per source rollout (None = every complete tool-run boundary becomes a task). min/max_source_reward seed only from selected-outcome rollouts (e.g. recheck only incorrect attempts). prime-rl now writes per-env train record files (train_rollouts_.jsonl), so mixed-file filtering is a fallback rather than the mechanism. Validated by three live gsm8k debug runs (recheck, tool-call continue, incorrect-only recheck: repair rate 31%→37% over 100 steps).

Update: rollout record index

Record files can now ship with a derived sibling index (prime-rl writes index_<env>.jsonl beside each train_rollouts_<env>.jsonl): one row per record line with selection fields (task_name, reward, branches, ...) and the line's byte span. New pure helpers in records.pyread_index / select_index_rows / iter_indexed_records — let load_tasks filter records index-side (already-replayed names, out-of-range source rewards, and un-compacted rollouts for continue/compaction) and then seek/read/parse only the surviving spans, opening the record file once. A file without an index full-parses exactly as before, and the parsed-record checks (foreign task validation, empty nodes) still run on every survivor. Covered by 3 new tests: index-selected loading matches full parse, filtered records are provably never parsed (invalid JSON at their spans), and branch-count prefiltering for compaction; plus explicit missing-index fallback asserts.

Update: cleanup pass

Consolidated the index API: read_index / select_index_rows / iter_indexed_records collapsed into one iter_selected_records generator (index-side filtering + span-selective reads, full parse when no index exists), with index_path / index_row kept as the small writer-side contract (prime-rl builds its index rows through them). Shared REPLAYED_PREFIXES constant for both replayed-name filters, max_anchors bounds via Annotated[int, Field(ge=1)] | None, and tool_call_seeds now uses per-branch token-count prefix sums (O(1) estimates per anchor) plus a Counter ordinal for name disambiguation. Behavior unchanged; tests reorganized around the shared helpers (one redundant reward-filter test folded into the index-vs-full-parse test).

faresoPrime and others added 3 commits July 4, 2026 05:14
…k tasks

Loads rollout records (Trace.to_record() JSONL) and turns each into a task
that re-enters the original task mid-way, delegating tools, user, lifecycle,
and scoring to the source taskset loaded by id — so new completions are
judged by the original env's verifier with its real toolkit.

- mode=continue, anchor=compaction: one task per context restart, detected
  structurally from the branch graph (no compaction-prompt matching, so
  records from different harnesses mix freely); the canonical [system, user]
  restart lowers to a string prompt and runs under any harness (incl. rlm)
- mode=continue, anchor=tool-call: one deterministically-drawn resume point
  per rollout, right after a complete tool-result run (Messages prompt;
  needs a message-seeding harness)
- mode=recheck: the final branch with truncation artifacts stripped plus a
  verification turn; sampled-only trace views mean rewards score the revision
- sandbox snapshot seam: records may carry trace.info["snapshots"] refs per
  anchor node; seeds restrict to snapshotted anchors and setup restores via
  restore_snapshot() (fails loudly until runtimes support capture)
- new recycling schemes subclass ReplayTaskset and override seeds()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…round-trips, seed edge cases

Adversarial review of the replay taskset surfaced structural issues; fixes:

- Environment's harness-capability gates now read taskset instance properties
  (defines_tools / defines_user) instead of class-override identity, and the
  rollout's state type comes from taskset.state_type() — a wrapping taskset
  (replay) delegates all three to its source, so replay runs under rlm and
  other capability-limited harnesses and stateful sources keep their typed
  per-rollout State
- ReplayConfig validation freezes record globs to the concrete matched files,
  so every env-server pool worker (elastic pools spawn workers mid-run)
  builds the identical task list; the recheck/anchor conflict check compares
  against the default rather than model_fields_set, so a dumped resolved
  config re-validates cleanly in pool workers
- seed builders enforce the snapshots contract everywhere (a record carrying
  refs offers only snapshotted anchors) with the ref keyed to the node the
  resume re-enters at; recheck truncates at the first partially-answered tool
  run (a dangling call makes the replayed prompt malformed); compaction
  detection skips the task's own launch context, so an auxiliary same-system
  branch sorting first can't fabricate a restart
- default/null harnesses ship a Messages prompt as a file in the runtime
  instead of the INITIAL_MESSAGES env var — replayed histories are exactly
  the long contexts that overflow MAX_ARG_STRLEN
- config-resolution, glob-freeze, and delegation tests pin the above

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lumbing, cheaper record loading

Applies a reuse/simplification/efficiency/altitude review of the replay feature:

- hook discovery becomes a Taskset seam: stops() and defines_group_rewards()
  (methods, not properties — getmembers evaluates properties during the
  discovery walk, so a discovery-running property recurses) plus
  needs_container; rollout/server/episode/env/validate call the seams and
  replay overrides them, replacing the bound-method setattr grafting in
  load_tasks
- Messages-prompt file transport hoisted to Harness.write_message_prompt
  (shared INITIAL_MESSAGES_FILE), used by default+null; stale env-var comment
  in the null harness dropped; programs read via Path.read_text
- expand_records() is the one shared glob expansion (config freeze +
  load_tasks); foreign records are filtered on task fields alone before the
  full-trace validation (nodes with token ids are far too big to validate for
  a line we discard); estimate_tokens estimates from message text instead of
  re-serializing every message
- restore_snapshot docstring records the endgame (promote the ref to a base
  Task field alongside image/workdir when snapshot support lands)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/tasksets/replay/records.py
Comment thread verifiers/v1/tasksets/replay/records.py
… source

replay.follow=true skips the validation-time glob freeze and makes load_tasks
incremental and append-only (numeric-aware file order, only new files parsed,
tasks only ever appended) so a run can replay its own rollouts as it produces
them. The env server refreshes via a new Taskset.reload_tasks() seam — on info
and on an out-of-range task index — and append-only reloads keep indices
aligned across pool workers refreshing at different times. Records produced by
the replay env itself (continue:/recheck: task names) are skipped so seeds
don't compound. Empty-at-startup is legal in follow mode (records appear with
the first steps).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# `follow` reloads stay aligned across pool workers refreshing at different times.
if "_tasks" not in self.__dict__: # first load on this instance
self._tasks, self._files_done, self._snapshots = [], set(), {}
new_paths = [path for path in paths if path not in self._files_done]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium replay/taskset.py:215

In follow mode, load_tasks permanently ignores records appended to a JSONL file it has already read. It tracks only file paths in _files_done and excludes any previously seen path from new_paths, so when rollouts/step_N/train_rollouts.jsonl grows after the first read, the new lines are never parsed — online self-replay silently drops tasks from the current step. Consider tracking a per-file byte offset (or line count) and re-reading from that position for files already seen, instead of skipping them outright.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 215:

In `follow` mode, `load_tasks` permanently ignores records appended to a JSONL file it has already read. It tracks only file paths in `_files_done` and excludes any previously seen path from `new_paths`, so when `rollouts/step_N/train_rollouts.jsonl` grows after the first read, the new lines are never parsed — online self-replay silently drops tasks from the current step. Consider tracking a per-file byte offset (or line count) and re-reading from that position for files already seen, instead of skipping them outright.

Following (incremental, append-only re-expansion of the records globs) is now
the only behavior: a static finished-run source is trivially aligned (all
files present and sorted at first load), and a growing own-run source is the
online case. Empty matches warn instead of raising (records may not exist
until the first step ships). Caveat documented: pool-worker index alignment
assumes files appear in glob order — avoid multi-writer record trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/tasksets/replay/taskset.py Outdated
# writes every field explicitly, and must re-validate cleanly.
if self.mode == "recheck" and self.anchor != "compaction":
raise ValueError("`anchor` only applies to mode='continue'")
# Freeze globs to the matched files: every env-server pool worker re-validates this

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High replay/taskset.py:146

ReplayConfig._validate documents that it freezes the records globs to the matched files so every pool worker sees the same file set, but it never actually replaces self.records with the matched list — the method just returns self unchanged. As a result, load_tasks re-expands the globs on every call. A worker started later, after new record files have appeared, sees extra files and assigns different tasks to the same numeric idx values than an earlier worker. Requests routed by task index then execute or score the wrong replay task across pool workers. The validator should replace self.records with the resolved file list before returning, or freeze the expansion in a separate field that load_tasks reads instead of the raw globs.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 146:

`ReplayConfig._validate` documents that it freezes the `records` globs to the matched files so every pool worker sees the same file set, but it never actually replaces `self.records` with the matched list — the method just returns `self` unchanged. As a result, `load_tasks` re-expands the globs on every call. A worker started later, after new record files have appeared, sees extra files and assigns different tasks to the same numeric `idx` values than an earlier worker. Requests routed by task index then execute or score the wrong replay task across pool workers. The validator should replace `self.records` with the resolved file list before returning, or freeze the expansion in a separate field that `load_tasks` reads instead of the raw globs.

… comments

- ReplayTaskset incremental state (_tasks/_files_done/_snapshots) becomes lazy
  cached_properties, dropping the "_tasks in self.__dict__" first-load guard
  and the mutable class-attribute defaults.
- Drop the dead glob-freeze comment left in ReplayConfig._validate and the
  stale "config validation freezes with it" claim in expand_records; scrub
  leftover "follow mode/knob" wording from docstrings.
- server.py: fix import order; update the stale "index range is fixed for the
  server's lifetime" comment.
- Tighten _natural's return annotation.

No behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
self._refresh_tasks()
return self.tasks[task_idx]

def _refresh_tasks(self) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium serve/server.py:126

_refresh_tasks() replaces self.tasks with a new list, but self.serving() was already entered at server startup with the original list object. When the taskset grows (e.g. starts empty and gains tasks later), Environment.shared_tools keeps the stale list reference, so newly discovered tasks never get their shared tool servers. Replay tasksets that rely on shared MCP tools will run those later tasks without the intended shared resources, changing rollout behavior. Consider re-entering serving() (or rebuilding shared tools) when the task list actually changes in _refresh_tasks(), or otherwise ensuring shared serving resources track the refreshed task list.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/serve/server.py around line 126:

`_refresh_tasks()` replaces `self.tasks` with a new list, but `self.serving()` was already entered at server startup with the original list object. When the taskset grows (e.g. starts empty and gains tasks later), `Environment.shared_tools` keeps the stale list reference, so newly discovered tasks never get their shared tool servers. Replay tasksets that rely on `shared` MCP tools will run those later tasks without the intended shared resources, changing rollout behavior. Consider re-entering `serving()` (or rebuilding shared tools) when the task list actually changes in `_refresh_tasks()`, or otherwise ensuring shared serving resources track the refreshed task list.

Comment thread verifiers/v1/tasksets/replay/taskset.py Outdated
skipped = {"replayed": 0, "foreign": 0, "empty": 0, "no_seed": 0, "overlong": 0}
for record in iter_records(Path(path) for path in new_paths):
name = (record.get("task") or {}).get("name") or ""
if isinstance(name, str) and name.startswith(("continue:", "recheck:")):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium replay/taskset.py:212

load_tasks silently discards any source record whose task name begins with "continue:" or "recheck:", treating it as this taskset's own recycled output. Because Task.name is arbitrary user data from the source environment, a legitimate source task named e.g. "recheck: algebra" or "continue: step 2" is dropped and can never be replayed, even though it is valid source data. The name-prefix heuristic is an unreliable way to detect self-output. Consider tagging replayed output with a dedicated marker field rather than overloading the user-facing name.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 212:

`load_tasks` silently discards any source record whose task name begins with `"continue:"` or `"recheck:"`, treating it as this taskset's own recycled output. Because `Task.name` is arbitrary user data from the source environment, a legitimate source task named e.g. `"recheck: algebra"` or `"continue: step 2"` is dropped and can never be replayed, even though it is valid source data. The name-prefix heuristic is an unreliable way to detect self-output. Consider tagging replayed output with a dedicated marker field rather than overloading the user-facing `name`.

f"{reason}={count}" for reason, count in skipped.items() if count
),
)
self._files_done.update(new_paths)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium replay/taskset.py:259

load_tasks() only updates _files_done at the very end of the batch. If any later file/line in new_paths raises (e.g. malformed JSON in iter_records(), or trace_cls.model_validate(record) rejects a bad trace), every earlier valid file in that batch is left unmarked. The next reload_tasks() reparses those already-consumed files and appends duplicate tasks, so task indices stop being stable across pool workers. Marking each file done as it's parsed prevents the duplicates.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 259:

`load_tasks()` only updates `_files_done` at the very end of the batch. If any later file/line in `new_paths` raises (e.g. malformed JSON in `iter_records()`, or `trace_cls.model_validate(record)` rejects a bad trace), every earlier valid file in that batch is left unmarked. The next `reload_tasks()` reparses those already-consumed files and appends duplicate tasks, so task indices stop being stable across pool workers. Marking each file done as it's parsed prevents the duplicates.

"replay `records` matched no files (yet): %s", self.config.records
)
task_cls = task_type(self.config.source.id)
trace_cls = Trace[task_cls]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium replay/taskset.py:202

load_tasks() validates each record as Trace[task_cls] (the base Trace type), which forbids unknown fields. Source tasksets can subclass Trace to persist extra trace fields, and their rollout records contain those extra keys — so trace_cls.model_validate(record) raises ValidationError and the record is skipped. Replay therefore drops all records from any taskset that uses a custom Trace subclass. Use the source taskset's own Trace subclass type (obtainable via self.source.trace_type() or equivalent) for validation instead of the generic base type.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 202:

`load_tasks()` validates each record as `Trace[task_cls]` (the base `Trace` type), which forbids unknown fields. Source tasksets can subclass `Trace` to persist extra trace fields, and their rollout records contain those extra keys — so `trace_cls.model_validate(record)` raises `ValidationError` and the record is skipped. Replay therefore drops all records from any taskset that uses a custom `Trace` subclass. Use the source taskset's own `Trace` subclass type (obtainable via `self.source.trace_type()` or equivalent) for validation instead of the generic base type.

… rollouts

e.g. max_source_reward=0.5 rechecks only incorrect attempts on a 0/1 env,
concentrating groups where revision can flip the outcome (the gsm8k debug run
showed 81% of unfiltered recheck seeds were already-correct attempts, mostly
zero-advantage groups).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread verifiers/v1/tasksets/replay/taskset.py Outdated
continue
if seed.snapshot is not None:
self._snapshots[len(tasks)] = seed.snapshot
tasks.append(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High replay/taskset.py:270

load_tasks() overwrites every replayed task's prompt with seed.prompt, even when the source task's original prompt was None. The server uses task.prompt is None as the signal to let the user() simulator open the conversation on the initial respond("") call, so replay tasks lose that opening turn. In environments like alphabet_sort_v1, this leaves the simulator queue one turn behind and injects the initial prompt again after the first assistant reply, corrupting the replayed conversation and scoring the wrong trajectory.

This happens at trace.task.model_copy(update={... "prompt": seed.prompt ...}) — it unconditionally replaces the original prompt. Consider preserving the None semantics when the source task had no prompt, or signaling the replay-seeded context differently so the server's opening-turn detection is not disrupted.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 270:

`load_tasks()` overwrites every replayed task's `prompt` with `seed.prompt`, even when the source task's original `prompt` was `None`. The server uses `task.prompt is None` as the signal to let the `user()` simulator open the conversation on the initial `respond("")` call, so replay tasks lose that opening turn. In environments like `alphabet_sort_v1`, this leaves the simulator queue one turn behind and injects the initial prompt again after the first assistant reply, corrupting the replayed conversation and scoring the wrong trajectory.

This happens at `trace.task.model_copy(update={... "prompt": seed.prompt ...})` — it unconditionally replaces the original prompt. Consider preserving the `None` semantics when the source task had no prompt, or signaling the replay-seeded context differently so the server's opening-turn detection is not disrupted.

faresoPrime and others added 4 commits July 7, 2026 22:35
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tool_call_seed's single draw generalizes to tool_call_seeds: the same
candidate anchors (complete tool-result runs, snapshot-gated, deduped
across branches), but max_anchors of them drawn deterministically and
kept in trajectory order — None seeds every valid anchor. Names stay
continue:<trace8>:tool-call<pos>, disambiguated with an ordinal when
two branches anchor at the same in-branch index. Default 1 preserves
the current single uniform draw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Record files may ship with a derived sibling index (prime-rl writes
index_<env>.jsonl beside each train_rollouts_<env>.jsonl): one row per record
line with selection fields (task_name, reward, branches, ...) and the line's
byte span. read_index / select_index_rows / iter_indexed_records filter
records index-side — already-replayed names, out-of-range source rewards,
and (for continue/compaction) un-compacted rollouts — then parse only the
surviving spans. A record file without an index full-parses as before; the
parsed-record checks (foreign task validation, empty nodes) still run on
every survivor.

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

- Collapse read_index / select_index_rows / iter_indexed_records into
  iter_selected_records: index-side filtering (skip counts updated in
  place) with span-selective reads, full parse when no index exists.
- Keep index_path / index_row as the small cross-repo contract with the
  record writer (prime-rl); index_row documents required selection keys
  vs informational provenance.
- REPLAYED_PREFIXES module constant shared by the index-side and
  parse-side already-replayed filters.
- max_anchors bounds via Annotated[int, Field(ge=1)] | None instead of a
  hand-written validator line.
- tool_call_seeds: per-branch prefix sums make token estimates O(1) per
  anchor, prefix sliced once, Counter ordinal for name disambiguation.
- Tests: hoist _echo_trace/_load_replay, fold _replay_taskset away, drop
  the reward-filter test subsumed by the index-vs-full-parse test's
  no-index leg, build _index_row via index_row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +298 to +301
prefix = nodes[: end + 1]
messages = [node.message for node in prefix]
if messages[0].role == "system":
messages = messages[1:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium replay/records.py:298

tool_call_seeds always strips the leading system message from the seeded history. When the source task's system prompt lives in-band in Task.prompt (as Environment builds by prepending it into the message list instead of populating Task.system_prompt), the seed loses it entirely — the harness re-emits Task.system_prompt, which is empty, so the replayed rollout resumes with different instructions than the original. Consider preserving the system message in the seed so the replayed context matches what the original rollout actually had.

     for nodes, end, tokens in candidates:
         prefix = nodes[: end + 1]
         messages = [node.message for node in prefix]
-        if messages[0].role == "system":
-            messages = messages[1:]
         base = f"continue:{trace.id[:8]}:tool-call{end}"
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/records.py around lines 298-301:

`tool_call_seeds` always strips the leading system message from the seeded history. When the source task's system prompt lives in-band in `Task.prompt` (as `Environment` builds by prepending it into the message list instead of populating `Task.system_prompt`), the seed loses it entirely — the harness re-emits `Task.system_prompt`, which is empty, so the replayed rollout resumes with different instructions than the original. Consider preserving the system message in the seed so the replayed context matches what the original rollout actually had.

Comment on lines +274 to +276
for end, node in enumerate(nodes):
if node.message.role != "tool" or id(node) in seen:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium replay/records.py:274

tool_call_seeds can produce replay seeds from fabricated context that was never part of the original rollout. When a branch carries copied tool messages (e.g. from a retokenization split or compaction), those nodes have sampled=False, but the function only checks node.message.role == "tool" and never checks node.sampled or issuer.sampled. This creates continue:...:tool-call seeds from context the source model never produced, polluting the replay taskset with invalid resume points. Consider filtering candidates so the issuing assistant node and tool result nodes must all have sampled=True.

-        for end, node in enumerate(nodes):
-            if node.message.role != "tool" or id(node) in seen:
+        for end, node in enumerate(nodes):
+            if node.message.role != "tool" or not node.sampled or id(node) in seen:
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/records.py around lines 274-276:

`tool_call_seeds` can produce replay seeds from fabricated context that was never part of the original rollout. When a branch carries copied `tool` messages (e.g. from a retokenization split or compaction), those nodes have `sampled=False`, but the function only checks `node.message.role == "tool"` and never checks `node.sampled` or `issuer.sampled`. This creates `continue:...:tool-call` seeds from context the source model never produced, polluting the replay taskset with invalid resume points. Consider filtering candidates so the issuing assistant node and tool result nodes must all have `sampled=True`.

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.

2 participants