Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c3eab90
feat(v1): grade in an isolated box, with Harbor-native artifacts
rasdani Jul 27, 2026
a657bd4
refactor(v1): slim artifacts.py, and narrow the symlink rule
rasdani Jul 27, 2026
a8c7896
refactor(v1): drop archive vetting, accept the tampered-tar risk
rasdani Jul 27, 2026
ab941d8
refactor(v1): rename capture_patch's `publish` to `write_path`
rasdani Jul 27, 2026
6a18ba9
refactor(v1): drop CollectedArtifact, return a dict of root -> archive
rasdani Jul 27, 2026
af508ac
refactor(v1): move fire-and-forget teardown onto Runtime.stop_nowait
rasdani Jul 27, 2026
85a3af6
refactor(v1): drop stop_nowait, tear the solver box down normally
rasdani Jul 27, 2026
91fa03b
refactor(v1): drop typo guards on artifact sources
rasdani Jul 27, 2026
e787bc9
fix(v1): let an unpinned judge runtime inherit the solver's
rasdani Jul 27, 2026
cce0921
Merge origin/main into feat/isolated-grading-artifacts
rasdani Jul 27, 2026
4492b14
fix(v1): resolve relative artifact sources against the runtime workdir
rasdani Jul 27, 2026
8355eaf
refactor(v1): inline the workdir resolution
rasdani Jul 27, 2026
9fe09e0
fix(v1): attribute patch-capture failures, and skip grading on infra …
rasdani Jul 27, 2026
245f806
docs(v1): say why the failed-solver path returns instead of raising
rasdani Jul 27, 2026
a6b82e3
fix(v1): keep `shared` as the agentic-judge default
rasdani Jul 27, 2026
e7ecfaf
fix(v1): capture the agent's edits only, not the image's untracked files
rasdani Jul 28, 2026
7236d4b
feat(v1): `snapshot_untracked`, the exact form of the same exclusion
rasdani Jul 28, 2026
5564d3f
refactor(v1): drop the mtime cutoff, keep only the recorded set
rasdani Jul 28, 2026
9ac6daf
refactor(v1): address isolated grading review feedback (#2160)
rasdani Jul 29, 2026
615efbb
Merge origin/main into feat/isolated-grading-artifacts
rasdani Jul 29, 2026
960d45b
refactor(v1): one mode-neutral workspace note
rasdani Jul 29, 2026
132d4b3
Apply suggestion from @macroscopeapp[bot]
rasdani Jul 29, 2026
4228c44
fix(v1): address isolated grading review feedback
hallerite Jul 30, 2026
3f8917c
Merge remote-tracking branch 'origin/main' into codex/pr-2144-takeover
hallerite Jul 30, 2026
fef21cd
fix(v1): validate isolated judge runtime early
hallerite Jul 30, 2026
96f2b59
fix(v1): disambiguate judge text sources
hallerite Jul 30, 2026
858ac72
test(v1): give coding harnesses token headroom
hallerite Jul 30, 2026
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
19 changes: 19 additions & 0 deletions docs/v1/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,22 @@ Just like tasksets and harnesses, an `Env` can be user-defined for full expressi
| `single-agent` | `agent` | (default) one `agent` plays the taskset |
| `best-of-n` | `agent` | `n` independent attempts per episode; its metrics mark the argmax-reward sibling (`best`) and whether any reached `--env.threshold` (`pass_at_n`) — rejection sampling and pass@k. |
| `agentic-judge` | `solver`, `judge` | the solver plays the task; a code-executing judge agent verifies the finished attempt with real execution. |

## Grading artifacts

Use artifacts to carry files between runtimes. Files written to
`/logs/artifacts/` are collected implicitly; declare other paths on the task data:

```python
class MyData(vf.TaskData):
artifacts: list[vf.Artifact] = [
vf.Artifact(source="/work/report", exclude=[".git"])
]


class MyTask(vf.Task[MyData]):
async def finalize(self, trace: vf.Trace, runtime: vf.Runtime) -> None:
trace.state.artifacts = await vf.collect(runtime, self.data.artifacts)
```

Declared paths must exist when collected. The implicit directory is optional.
5 changes: 4 additions & 1 deletion docs/v1/harbor.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,13 @@ routes take precedence over deny rules, while ordinary Prime deny rules are appl
unchanged and may block a matching route. Restricted Harbor tasks require Docker or a
Prime VM; Prime accepts host-level entries.

## Artifacts and collect hooks

`artifacts = [...]` and `[[verifier.collect]]` are read from `task.toml` ([Harbor Docs](https://www.harborframework.com/docs/run-jobs/results-and-artifacts)). Collect hooks run in the agent's box from the task's `finalize`, which is Harbor's own ordering — after the agent phase, before collection — and declared paths plus the `/logs/artifacts/` convention dir are then carried into the grading box and restored at their original paths ("no translation", as in Harbor).

## Shortcomings

verifiers does not have parity with Harbor yet, so some features are missing and currently being worked on. The most notable missing features right now are:

- Switching to a different verifier-phase network policy ([Harbor Docs](https://www.harborframework.com/docs/tasks/network-policy))
- Shared & separate verifiers ([Harbor Docs](https://www.harborframework.com/docs/tasks#verifier-environment-shared-vs-separate))
- Multi-step tasks ([Harbor Docs](https://www.harborframework.com/docs/tasks/multi-step))
17 changes: 2 additions & 15 deletions tests/v1/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,24 +392,12 @@ async def test_env_id_best_of_n(run_v1, tmp_path):

@pytest.mark.e2e
async def test_env_id_agentic_judge(run_v1, tmp_path):
"""The agentic judge over the echo taskset (needs docker): the box is
provisioned once from the solver's runtime policy, the solver plays in it,
the judge lands in the SAME box with the graded trace uploaded,
investigates with real execution, and its parsed verdict
lands on the solver's trace under the spec's reward key. Wiring, not taste:
the judge followed the verdict-file contract — the grade itself is the
model's call. Exercises the config surface too: a policy-only prompt
override (the verdict contract is appended regardless) and
reward-composition weights."""
traces = await run_v1(
"echo-v1",
harness=None, # seats pin their own harness; there is no run-level one
harness=None,
env={
"id": "agentic-judge",
# The solver owns the shared box, so the container is pinned here.
"solver": {"harness": {"id": "bash"}, "runtime": {"type": "docker"}},
# The judge reads the trace and reasons before it writes the
# verdict file; the shared 2048-token run cap truncates it mid-audit.
"judge": {
"harness": {"id": "bash"},
"max_output_tokens": 8192,
Expand All @@ -428,10 +416,9 @@ async def test_env_id_agentic_judge(run_v1, tmp_path):
(judge,) = [t for t in traces if t.agent_name == "judge"]
assert solver.ok and judge.ok
assert judge.trainable is False
# The task's own reward keeps its raw score; the rescale lands on the weight.
assert solver.rewards["echoed"].score == 1.0
assert solver.rewards["echoed"].weight == 0.5
assert isinstance(judge.info.get("verdict"), dict) # scraped off the box
assert isinstance(judge.info.get("verdict"), dict)
assert 0.0 <= solver.rewards["judge"].score <= 1.0


Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions verifiers/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

from verifiers.v1.acp import ACP
from verifiers.v1.agent import Agent, Agents, Interaction, Segment, make_agent
from verifiers.v1.artifacts import (
ARTIFACTS_DIR,
Artifact,
collect,
restore,
)
from verifiers.v1.clients import (
BaseClientConfig,
Client,
Expand Down Expand Up @@ -292,6 +298,11 @@
"PATCH_CAP_BYTES",
"capture_patch",
"resolve_head",
# grading artifacts
"ARTIFACTS_DIR",
"Artifact",
"collect",
"restore",
# scoring
"compare_stdout_results",
"extract_boxed_answer",
Expand Down
144 changes: 144 additions & 0 deletions verifiers/v1/artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Artifact collection and restoration across runtimes."""

from __future__ import annotations

import logging
import shlex
import uuid
from pathlib import PurePosixPath
from typing import TYPE_CHECKING

from pydantic import Field

from verifiers.v1.types import StrictBaseModel

if TYPE_CHECKING:
from verifiers.v1.runtimes import Runtime

logger = logging.getLogger(__name__)

ARTIFACTS_DIR = "/logs/artifacts"
"""Implicit artifact directory; tasks that write here need no declaration."""

MAX_ARTIFACT_BYTES = 32 * 1024 * 1024
"""Ceiling per collection. Sized for a delta, not a tree: the grading box boots from the
agent's image, so the repo is already there and only its output has to travel."""


class Artifact(StrictBaseModel):
"""One path to restore at the same location in another runtime."""

source: str
exclude: list[str] = Field(default_factory=list)
"""`tar --exclude` patterns, applied when `source` is a directory."""


async def collect(
runtime: Runtime, artifacts: list[Artifact] | None = None
) -> dict[str, bytes]:
"""Tar the convention dir and every declared path out of `runtime`.

Keyed by source path; the values are tar archives. Insertion order is the order
they were declared, and a path cannot be collected twice.

A declared source that is missing raises: it was declared because grading needs it,
and grading a partial state scores the rollout wrong rather than failing it. The
implicit convention sweep is exempt — most tasks never write there.

Each source is archived separately so its exclude patterns stay local.
"""
# Resolve relative sources against the runtime workdir. Joining also normalises
# `/work/` to `/work`, so one tree cannot key two entries (the source is both the
# dict key and `restore`'s rm -rf target).
workdir = PurePosixPath(getattr(runtime.config, "workdir", "") or "/")
declared = [
a.model_copy(update={"source": str(workdir / a.source)})
for a in artifacts or []
]
convention = PurePosixPath(ARTIFACTS_DIR)
sweep = not any(
(p := PurePosixPath(a.source)) == convention
or p.is_relative_to(convention)
or convention.is_relative_to(p)
for a in declared
)
entries = ([Artifact(source=ARTIFACTS_DIR)] if sweep else []) + declared

collected: dict[str, bytes] = {}
budget = MAX_ARTIFACT_BYTES
for artifact in entries:
source = artifact.source
if (await runtime.run(["test", "-e", source], {})).exit_code != 0:
Comment thread
rasdani marked this conversation as resolved.
Resolved
if sweep and source == ARTIFACTS_DIR:
continue
raise RuntimeError(
f"declared artifact {source!r} does not exist in the runtime"
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
archive = await _tar_out(runtime, artifact, budget)
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
budget -= len(archive)
collected[source] = archive
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

logger.debug("collected artifact roots: %s", list(collected))
return collected


async def restore(runtime: Runtime, collected: dict[str, bytes]) -> None:
"""Extract `collected` in `runtime` at the original absolute paths."""
if not collected:
return
# Extraction writes to absolute paths. In a container that is the point; under the
Comment thread
hallerite marked this conversation as resolved.
Resolved
# subprocess runtime it is the developer's own filesystem.
if getattr(runtime.config, "type", None) == "subprocess":
raise RuntimeError(
"refusing to restore artifacts into the subprocess runtime: extraction "
"writes to absolute paths on the host. Grade in a container."
)
# Clear every root up front, not per entry: a later nested root would otherwise
# delete content an earlier one just restored. Clearing also drops any file or
# symlink the image left at the target.
roots = " ".join(shlex.quote(root) for root in collected)
await _run(runtime, f"rm -rf -- {roots}", "clear artifact roots")
for root, archive in collected.items():
path = f"/tmp/vf-artifact-{uuid.uuid4().hex}.tar"
await runtime.write(path, archive)
await _run(
runtime,
f"tar -xf {shlex.quote(path)} -C / && rm -f {shlex.quote(path)}",
f"restore artifact {root!r}",
)


async def _tar_out(runtime: Runtime, artifact: Artifact, budget: int) -> bytes:
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
path = f"/tmp/vf-artifact-{uuid.uuid4().hex}.tar"
excludes = " ".join(f"--exclude={shlex.quote(p)}" for p in artifact.exclude)
try:
await _run(
runtime,
f"tar -cf {shlex.quote(path)} -C / {excludes} -- "
f"{shlex.quote(artifact.source.lstrip('/'))}",
f"collect artifact {artifact.source!r}",
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
)
# Size it in the box: an oversized collection is refused before it reaches host
# memory, not after.
sized = await runtime.run(["sh", "-c", f"wc -c < {shlex.quote(path)}"], {})
if (raw := sized.stdout.strip()).isdigit() and int(raw) > budget:
raise RuntimeError(
f"artifact {artifact.source!r} takes the collection over the "
f"{MAX_ARTIFACT_BYTES} byte limit. The grading box boots from the "
"agent's image, so only the delta needs to travel — narrow the source "
"or add `exclude` patterns."
)
return await runtime.read(path)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
finally:
# Best-effort: the box is about to be destroyed and the name is unique per call.
try:
await runtime.run(["rm", "-f", path], {})
except Exception:
logger.debug("failed to remove %s", path, exc_info=True)


async def _run(runtime: Runtime, command: str, action: str) -> None:
result = await runtime.run(["sh", "-c", command], {})
if result.exit_code:
detail = (result.stderr or result.stdout).strip()[-500:]
raise RuntimeError(f"failed to {action}: {detail}")
Loading
Loading