Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions environments/compact/compact/annotate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Producer-side helpers: locate the compaction resume points a harness should tag.

A compacting rollout splits into branches (one per context rewrite). Each compaction exposes
two replay resume points:

- ``compaction_after`` — the post-compaction branch start (the rewritten ``[system, user(notes)]``).
Resuming here, the model continues solving *from* the compaction message.
- ``compaction_before`` — the leaf of the branch that compaction summarized (the prior turn's
response). Resuming here, the model is back in the pre-compaction context and its continuation
*writes* the compaction itself (then keeps solving).

This module only *finds* the nodes; where the tag is stored is the A/B decision (Option A:
``trace.info``; Option B: ``MessageNode.kind``) and lives at the harness write site.
"""

from __future__ import annotations

from verifiers.v1 import graph
from verifiers.v1.trace import Trace


def compaction_after_nodes(trace: Trace) -> list[int]:
"""Post-compaction branch starts: the first node of each forked branch. A node with >1
child is a fork point; ``children[0]`` is the original line, ``children[1:]`` are the
rewritten (post-compaction) branches. The compacting harness rewrites every turn, so each
is a compaction boundary."""
children: dict[int | None, list[int]] = {}
for nid, node in enumerate(trace.nodes):
children.setdefault(node.parent, []).append(nid)
starts: list[int] = []
for kids in children.values():
if len(kids) > 1:
starts.extend(kids[1:])
return starts


def compaction_before_nodes(trace: Trace) -> list[int]:
"""Pre-compaction points: every branch leaf except the final-answer branch. Each such leaf
is the turn whose output was summarized into the next branch's compaction message, so
resuming there puts the model right before it writes a compaction."""
leaves = sorted(graph.leaves(trace))
return leaves[:-1] if len(leaves) > 1 else []
13 changes: 12 additions & 1 deletion environments/compact/compact/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from verifiers.v1.runtimes import ProgramResult, Runtime
from verifiers.v1.trace import Trace

from compact.annotate import compaction_after_nodes, compaction_before_nodes

PROGRAM_SOURCE = (Path(__file__).resolve().parent / "program.py").read_text()


Expand Down Expand Up @@ -51,4 +53,13 @@ async def launch(
{"mcpServers": {name: {"url": url} for name, url in mcp_urls.items()}}
)
program = await runtime.prepare_uv_script(PROGRAM_SOURCE, self.config.env)
return await runtime.run_program([*program, trace.task.prompt], env)
result = await runtime.run_program([*program, trace.task.prompt], env)

# Tag compaction resume points on the finished graph (Option B: typed MessageNode.kind).
Comment on lines +56 to +58

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 compact/harness.py:56

launch unconditionally overwrites trace.nodes[*].kind after runtime.run_program(...) returns, before the caller checks result.exit_code. When the compact harness crashes or exits non-zero after producing a partial trace, the compaction resume tags are still persisted on an errored rollout. ReplayTaskset.load_tasks() reads these tags from every stored trace without filtering trace.error, so failed or incomplete compact rollouts generate replay tasks that resume from bogus prefixes. Consider skipping the tagging when result.exit_code indicates failure, or guarding load_tasks() to skip traces with trace.error.

        result = await runtime.run_program([*program, trace.task.prompt], env)
+        if result.exit_code != 0:
+            return result
+
         # Tag compaction resume points on the finished graph (Option B: typed MessageNode.kind).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @environments/compact/compact/harness.py around lines 56-58:

`launch` unconditionally overwrites `trace.nodes[*].kind` after `runtime.run_program(...)` returns, before the caller checks `result.exit_code`. When the compact harness crashes or exits non-zero after producing a partial trace, the compaction resume tags are still persisted on an errored rollout. `ReplayTaskset.load_tasks()` reads these tags from every stored trace without filtering `trace.error`, so failed or incomplete compact rollouts generate replay tasks that resume from bogus prefixes. Consider skipping the tagging when `result.exit_code` indicates failure, or guarding `load_tasks()` to skip traces with `trace.error`.

# Two points per compaction: the post-compaction branch start (resume to continue from
# the compaction message) and the pre-compaction branch leaf (resume to regenerate it).
for nid in compaction_after_nodes(trace):
trace.nodes[nid].kind = "compaction_after"
for nid in compaction_before_nodes(trace):
trace.nodes[nid].kind = "compaction_before"
return result
21 changes: 21 additions & 0 deletions environments/replay/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[project]
name = "replay"
version = "0.1.0"
description = "replay — replay-buffer tasksets (recheck / judge / compaction_before / compaction_after)."
requires-python = ">=3.10"

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/pyproject.toml:5

requires-python = ">=3.10" allows installation on Python 3.10, but the only dependency verifiers requires >=3.11,<3.14. On Python 3.10, dependency resolution fails to find a compatible verifiers build, so installing replay fails. Consider aligning this constraint with verifiers by using >=3.11,<3.14.

Suggested change
requires-python = ">=3.10"
requires-python = ">=3.11,<3.14"
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @environments/replay/pyproject.toml around line 5:

`requires-python = ">=3.10"` allows installation on Python 3.10, but the only dependency `verifiers` requires `>=3.11,<3.14`. On Python 3.10, dependency resolution fails to find a compatible `verifiers` build, so installing `replay` fails. Consider aligning this constraint with `verifiers` by using `>=3.11,<3.14`.

dependencies = ["verifiers"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
# One distribution ships five top-level modules: the shared library + the four selectable
# tasksets (each a taskset id, e.g. `--taskset.id replay_recheck`).
packages = [
"replay_common",
"replay_recheck",
"replay_judge",
"replay_compaction_after",
"replay_compaction_before",
]
23 changes: 23 additions & 0 deletions environments/replay/replay_common/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""replay_common — shared base for the replay-buffer tasksets.

The four selectable tasksets live in sibling top-level modules, each fixing one ``KIND``:
``replay_recheck``, ``replay_judge``, ``replay_compaction_after``, ``replay_compaction_before``.
This module is the shared library (buffer sourcing, seeding, scoring); it is not itself a
selectable taskset.
"""

from replay_common.base import (
BaseReplayHarness,
BaseReplayTaskset,
ReplayConfig,
ReplayHarnessConfig,
ReplayTask,
)

__all__ = [
"BaseReplayTaskset",
"BaseReplayHarness",
"ReplayConfig",
"ReplayHarnessConfig",
"ReplayTask",
]
219 changes: 219 additions & 0 deletions environments/replay/replay_common/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"""Shared base for the replay-buffer tasksets.

Each concrete taskset/harness fixes one ``KIND`` (``recheck`` / ``judge`` / ``compaction_after``
/ ``compaction_before``) and lives in its own selectable module (``replay_recheck``, ...). This
base holds everything they share: buffer sourcing (offline materialize + online sampling),
seed building, snapshot restore, and scoring.

Sourcing (``config.mode``):
- ``offline`` — ``load_tasks`` materializes one task per resume point of this taskset's ``KIND``;
the bundled harness sees a non-empty ``task.prompt`` and just runs the default chat loop.
- ``online`` — ``load_tasks`` returns ``pool_size`` virtual slots and the harness samples a
``KIND`` resume point from the *live* buffer per rollout.

Scoring:
- ``recheck`` / ``compaction_*`` reuse the ORIGINAL env's verifier (``config.inner``).
- ``judge`` grades the model's verdict against the original rollout's reward (no inner verifier).
"""

from __future__ import annotations

import glob
import json
import random
from pathlib import Path
from typing import ClassVar, Literal

from pydantic import SerializeAsAny

from verifiers.v1.clients import RolloutContext
from verifiers.v1.dialects.chat import message_to_wire
from verifiers.v1.harnesses.default.harness import (
PROGRAM_SOURCE,
DefaultHarness,
DefaultHarnessConfig,
)
from verifiers.v1.loaders import load_taskset
from verifiers.v1.runtimes import ProgramResult, Runtime
from verifiers.v1.task import Task, WireTask
from verifiers.v1.taskset import Taskset, TasksetConfig
from verifiers.v1.trace import Trace, WireTrace

from replay_common.selector import (
DEFAULT_FOLLOWUP,
build_seed,
iter_traces,
resume_points,
snapshot_ref_of,
)


class ReplayTask(Task):
"""A replayed seed plus the provenance needed to score it (offline mode; online mode carries
the same provenance in ``trace.info["replay"]``)."""

source_trace_id: str = ""
resume_node: int = -1
snapshot_ref: str | None = None
original_task: dict = {}
original_reward: float = 0.0


class ReplayConfig(TasksetConfig):
mode: Literal["offline", "online"] = "offline"
buffer_glob: str = ""
"""Glob of stored-rollout JSONL files, e.g. ``.../rollouts/step_*/train_rollouts.jsonl``."""
followup: str = DEFAULT_FOLLOWUP
"""The user turn appended for the ``recheck`` taskset."""
judge_threshold: float = 0.5
"""``judge`` taskset: the original rollout counts as correct when ``original_reward >`` this."""
pool_size: int = 1024
"""Online mode: number of virtual task slots (num_tasks); the harness samples per rollout."""
inner: SerializeAsAny[TasksetConfig] = TasksetConfig()
"""The ORIGINAL env's taskset (the verifier to reuse). Empty id => no inner scoring."""


class ReplayHarnessConfig(DefaultHarnessConfig):
buffer_glob: str = ""
"""Online mode: the live buffer to sample from."""
followup: str = DEFAULT_FOLLOWUP


def _parse_verdict(trace: Trace) -> bool | None:
"""The model's yes/no judgment from its last response (``judge``). None if unclear."""
msgs = trace.assistant_messages
text = (msgs[-1].content or "").strip().lower() if msgs else ""
if text.startswith("yes") or text.startswith("correct"):
return True
if text.startswith("no") or text.startswith("incorrect"):
return False
has_yes, has_no = "yes" in text, "no" in text
return has_yes if has_yes != has_no else None


class BaseReplayTaskset(Taskset[ReplayTask, ReplayConfig]):
"""Concrete subclasses set ``KIND``; everything else is shared."""

KIND: ClassVar[str] = ""

def __init__(self, config: ReplayConfig) -> None:
super().__init__(config)
# Reuse the original env's verifier when configured (empty id => no inner scoring).
self._inner: Taskset | None = load_taskset(config.inner) if config.inner.id else None

def load_tasks(self) -> list[ReplayTask]:
if self.config.mode == "online":
return [ReplayTask(idx=i, prompt=None) for i in range(self.config.pool_size)]
tasks: list[ReplayTask] = []
for src in iter_traces(self.config.buffer_glob):
for pt in resume_points(src, kinds={self.KIND}):
tasks.append(
ReplayTask(
idx=len(tasks),
prompt=build_seed(src, pt, self.config.followup),
source_trace_id=src.id,
resume_node=pt["node"],
snapshot_ref=snapshot_ref_of(src, pt["node"]),
original_task=src.task.model_dump(),
original_reward=src.reward,
)
)
return tasks

async def setup(self, task: ReplayTask, runtime: Runtime) -> None:
# Offline exec/sandbox replay: restore to the resume point before the harness runs.
# (Online restores inside the harness; judge needs no sandbox.) Skeleton: refs are None.
if task.snapshot_ref is not None and self.KIND != "judge":
await runtime.restore(task.snapshot_ref)

async def score(self, trace: Trace, runtime: Runtime) -> None:
replay = trace.info.get("replay") # online: harness-stashed provenance
original_dump = replay["original_task"] if replay else getattr(trace.task, "original_task", {})
original_reward = replay["original_reward"] if replay else getattr(trace.task, "original_reward", 0.0)

if self.KIND == "judge":
verdict = _parse_verdict(trace)
correct = original_reward > self.config.judge_threshold
trace.record_reward("judge_match", 1.0 if verdict == correct else 0.0)
return

# recheck / compaction_*: reuse the ORIGINAL verifier with the original task swapped in.
if self._inner is None or not original_dump:
return
replay_task = trace.task
trace.task = WireTask.model_validate(original_dump)
try:
await self._inner.score(trace, runtime)
finally:
trace.task = replay_task


class BaseReplayHarness(DefaultHarness):
"""Bundled with each taskset (auto-selected via ``default_harness_id``). Offline: a materialized
``task.prompt`` is present, so defer to the default chat loop. Online: sample a ``KIND`` resume
point from the live buffer and seed the loop with it."""

KIND: ClassVar[str] = ""
SUPPORTS_MESSAGE_PROMPT = True

async def launch(
self,
ctx: RolloutContext,
trace: Trace,
runtime: Runtime,
endpoint: str,
secret: str,
mcp_urls: dict[str, str],
) -> ProgramResult:
if trace.task.prompt is not None: # offline: materialized seed -> default behavior
return await super().launch(ctx, trace, runtime, endpoint, secret, mcp_urls)

rng = random.Random(trace.id)
sample = self._sample(rng)
if sample is None: # buffer empty (warmup) / no matching points yet
trace.stop("replay_buffer_empty")
return ProgramResult(exit_code=0, stdout="", stderr="")
src, point = sample

ref = snapshot_ref_of(src, point["node"])
if ref is not None and self.KIND != "judge": # judge needs no sandbox; skeleton refs are None
await runtime.restore(ref)

trace.info["replay"] = {
"source_id": src.id,
"resume_node": point["node"],
"kind": point["kind"],
"original_task": src.task.model_dump(),
"original_reward": src.reward,
}
seed = build_seed(src, point, self.config.followup)
env = {**self.config.env}
env["INITIAL_MESSAGES"] = json.dumps([message_to_wire(m) for m in seed])
args = [f"--base-url={endpoint}", f"--api-key={secret}", f"--model={ctx.model}"]
if mcp_urls:
args.append(
"--mcp-config="
+ json.dumps({"mcpServers": {n: {"url": u} for n, u in mcp_urls.items()}})
)
program = await runtime.prepare_uv_script(PROGRAM_SOURCE, self.config.env)
return await runtime.run_program([*program, *args], env)

def _sample(self, rng: random.Random) -> tuple[Trace, dict] | None:
"""Scan the live buffer in random order; return the first (trace, ``KIND`` point) found."""
files = sorted(glob.glob(self.config.buffer_glob))
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
rng.shuffle(files)
for path in files:
try:
lines = Path(path).read_text().splitlines()
except OSError:
continue
rng.shuffle(lines)
for line in lines:
line = line.strip()
if not line:
continue
src = WireTrace.model_validate(json.loads(line))
points = resume_points(src, kinds={self.KIND})
if points:
return src, rng.choice(points)
return None
Loading
Loading