Skip to content

Commit 4897b31

Browse files
committed
feat(evaluator): add AgentEvalTaskset and filesystem evidence handle
Add the AgentEvalTaskset value type (a named, validated collection of agent-eval tasks with unique task ids) and extend LocalFilesystemEvidence with read_bytes, list(pattern), diff, and run_verifier. run_verifier executes against a throwaway overlay copy so stored evidence can never be mutated. Includes the FilesystemEntry / FilesystemDiff / CommandResult value types and example-only tests_pass / no_test_cheating metrics that score over filesystem evidence. Vendored mirror synced via make vendor. Signed-off-by: Arpit Singh (SW-CLOUD) <arpsingh@nvidia.com>
1 parent d9e1851 commit 4897b31

10 files changed

Lines changed: 623 additions & 75 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Reference metrics-over-evidence for this example (not SDK API).
5+
6+
These show how to score from the SDK's filesystem evidence handle instead of a
7+
stamped verifier reward:
8+
9+
* :class:`TestsPassMetric` runs a command against ``final_state`` filesystem
10+
evidence (in a throwaway overlay) and scores on exit 0.
11+
* :class:`NoTestCheatingMetric` diffs ``initial_state`` against ``final_state``
12+
and fails if the agent touched protected (e.g. test) paths.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from collections.abc import Sequence
18+
19+
from nemo_evaluator_sdk.metrics.protocol import MetricInput, MetricOutput, MetricOutputSpec, MetricResult
20+
21+
22+
class TestsPassMetric:
23+
"""Score ``True`` when a verifier command exits 0 against final-state evidence."""
24+
25+
def __init__(
26+
self,
27+
command: Sequence[str],
28+
*,
29+
evidence_name: str = "final_state",
30+
cwd: str = ".",
31+
timeout_s: float = 300.0,
32+
) -> None:
33+
self._command = list(command)
34+
self._evidence_name = evidence_name
35+
self._cwd = cwd
36+
self._timeout_s = timeout_s
37+
38+
@property
39+
def type(self) -> str:
40+
return "tests_pass"
41+
42+
def output_spec(self) -> list[MetricOutputSpec]:
43+
return [MetricOutputSpec.boolean("tests_pass")]
44+
45+
async def compute_scores(self, input: MetricInput) -> MetricResult:
46+
passed = False
47+
evidence = input.candidate.evidence
48+
if evidence is not None and evidence.get(self._evidence_name) is not None:
49+
handle = await evidence.filesystem(self._evidence_name)
50+
result = await handle.run_verifier(self._command, cwd=self._cwd, timeout_s=self._timeout_s)
51+
passed = result.ok
52+
return MetricResult(outputs=[MetricOutput(name="tests_pass", value=passed)])
53+
54+
55+
class NoTestCheatingMetric:
56+
"""Score ``False`` when the agent modified or deleted protected paths."""
57+
58+
def __init__(
59+
self,
60+
*,
61+
protected: Sequence[str] = ("tests/",),
62+
change_types: Sequence[str] = ("modified", "deleted"),
63+
initial_name: str = "initial_state",
64+
final_name: str = "final_state",
65+
) -> None:
66+
self._protected = tuple(protected)
67+
self._change_types = set(change_types)
68+
self._initial_name = initial_name
69+
self._final_name = final_name
70+
71+
@property
72+
def type(self) -> str:
73+
return "no_test_cheating"
74+
75+
def output_spec(self) -> list[MetricOutputSpec]:
76+
return [MetricOutputSpec.boolean("no_test_cheating")]
77+
78+
async def compute_scores(self, input: MetricInput) -> MetricResult:
79+
evidence = input.candidate.evidence
80+
clean = True
81+
if evidence is not None and evidence.get(self._initial_name) and evidence.get(self._final_name):
82+
initial = await evidence.filesystem(self._initial_name)
83+
final = await evidence.filesystem(self._final_name)
84+
diff = await initial.diff(final)
85+
violations = [
86+
entry for prefix in self._protected for entry in diff.changed(prefix=prefix, kinds=self._change_types)
87+
]
88+
clean = not violations
89+
return MetricResult(outputs=[MetricOutput(name="no_test_cheating", value=clean)])

packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/tasks.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,31 @@ def _validate_metric_references(self) -> AgentEvalTask:
130130
return self
131131

132132

133+
class AgentEvalTaskset(BaseModel):
134+
"""Named collection of tasks; ``id``/``name`` sugar around a task list."""
135+
136+
model_config = ConfigDict(extra="forbid")
137+
138+
id: str = Field(description="Stable taskset identifier.")
139+
name: str | None = Field(default=None, description="Human-readable taskset name.")
140+
tasks: list[AgentEvalTask] = Field(description="Tasks in this set; task ids must be unique.")
141+
142+
@field_validator("id")
143+
@classmethod
144+
def _id_must_not_be_empty(cls, value: str) -> str:
145+
if not value:
146+
raise ValueError("taskset id must not be empty")
147+
return value
148+
149+
@model_validator(mode="after")
150+
def _task_ids_unique(self) -> AgentEvalTaskset:
151+
ids = [task.id for task in self.tasks]
152+
duplicates = sorted({task_id for task_id in ids if ids.count(task_id) > 1})
153+
if duplicates:
154+
raise ValueError(f"duplicate taskset task ids: {duplicates}")
155+
return self
156+
157+
133158
class AgentEvalRunConfig(BaseModel):
134159
"""Configuration for a standalone agent-eval run."""
135160

packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/values/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
InputSchema,
1111
)
1212
from nemo_evaluator_sdk.values.datasets import DatasetInput, DatasetRows
13-
from nemo_evaluator_sdk.values.evidence import CandidateEvidence, EvidenceDescriptor, LocalFilesystemEvidence
13+
from nemo_evaluator_sdk.values.evidence import (
14+
CandidateEvidence,
15+
CommandResult,
16+
EvidenceDescriptor,
17+
FilesystemDiff,
18+
FilesystemEntry,
19+
LocalFilesystemEvidence,
20+
)
1421
from nemo_evaluator_sdk.values.metrics import (
1522
BLEU,
1623
F1,
@@ -98,7 +105,10 @@
98105
"BooleanValue",
99106
"CandidateEvidence",
100107
"CandidateOutput",
108+
"CommandResult",
101109
"ContinuousScore",
110+
"FilesystemDiff",
111+
"FilesystemEntry",
102112
"DatasetRow",
103113
"DatasetRows",
104114
"DefaultAggregateFieldName",

packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/values/evidence.py

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,65 @@
66
from __future__ import annotations
77

88
import asyncio
9+
import hashlib
10+
import shutil
11+
import tempfile
912
from pathlib import Path
10-
from typing import Any
13+
from typing import Any, Literal
1114
from urllib.parse import urlparse
1215

1316
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
1417

18+
# ``LocalFilesystemEvidence.list`` (the documented evidence API) shadows the
19+
# builtin inside that class body; alias it for annotations there.
20+
_StrList = list[str]
21+
22+
23+
class FilesystemEntry(BaseModel):
24+
"""One path that differs between two filesystem snapshots."""
25+
26+
model_config = ConfigDict(extra="forbid")
27+
28+
path: str
29+
change_type: Literal["added", "modified", "deleted"]
30+
31+
32+
class FilesystemDiff(BaseModel):
33+
"""Set of paths that changed between two filesystem snapshots."""
34+
35+
model_config = ConfigDict(extra="forbid")
36+
37+
entries: list[FilesystemEntry] = Field(default_factory=list)
38+
39+
def changed(
40+
self,
41+
*,
42+
prefix: str | None = None,
43+
kinds: set[str] | None = None,
44+
) -> list[FilesystemEntry]:
45+
"""Return entries optionally filtered by path prefix and change kind."""
46+
return [
47+
entry
48+
for entry in self.entries
49+
if (prefix is None or entry.path.startswith(prefix)) and (kinds is None or entry.change_type in kinds)
50+
]
51+
52+
53+
class CommandResult(BaseModel):
54+
"""Outcome of running a verifier command against filesystem evidence."""
55+
56+
model_config = ConfigDict(extra="forbid")
57+
58+
exit_code: int
59+
stdout: str = ""
60+
stderr: str = ""
61+
timed_out: bool = False
62+
63+
@property
64+
def ok(self) -> bool:
65+
"""Whether the command exited 0 without timing out."""
66+
return self.exit_code == 0 and not self.timed_out
67+
1568

1669
class LocalFilesystemEvidence:
1770
"""Constrained local filesystem handle for metric evidence access."""
@@ -42,17 +95,95 @@ async def read_text(self, relative_path: str | Path, *, encoding: str = "utf-8")
4295
path = self.path(relative_path)
4396
return await asyncio.to_thread(path.read_text, encoding=encoding)
4497

45-
async def iter_paths(self, relative_path: str | Path = ".", *, recursive: bool = False) -> list[str]:
98+
async def iter_paths(self, relative_path: str | Path = ".", *, recursive: bool = False) -> _StrList:
4699
"""Return stable relative path names under the evidence root."""
47100
base = self.path(relative_path)
48101
return await asyncio.to_thread(self._iter_paths_sync, base, recursive)
49102

50-
def _iter_paths_sync(self, base: Path, recursive: bool) -> list[str]:
103+
def _iter_paths_sync(self, base: Path, recursive: bool) -> _StrList:
51104
if base.is_file():
52105
return [base.relative_to(self._root).as_posix()]
53106
iterator = base.rglob("*") if recursive else base.iterdir()
54107
return sorted(path.relative_to(self._root).as_posix() for path in iterator)
55108

109+
async def read_bytes(self, relative_path: str | Path) -> bytes:
110+
"""Read a binary file under the evidence root."""
111+
path = self.path(relative_path)
112+
return await asyncio.to_thread(path.read_bytes)
113+
114+
async def list(self, pattern: str = "**/*") -> _StrList:
115+
"""Return relative posix paths of files matching ``pattern`` (files only)."""
116+
return await asyncio.to_thread(self._list_sync, pattern)
117+
118+
def _list_sync(self, pattern: str) -> _StrList:
119+
return sorted(path.relative_to(self._root).as_posix() for path in self._root.glob(pattern) if path.is_file())
120+
121+
async def diff(self, other: LocalFilesystemEvidence) -> FilesystemDiff:
122+
"""Diff this snapshot (before) against ``other`` (after) by file content hash."""
123+
return await asyncio.to_thread(self._diff_sync, other)
124+
125+
def _diff_sync(self, other: LocalFilesystemEvidence) -> FilesystemDiff:
126+
before = self._hashes()
127+
after = other._hashes()
128+
entries = [FilesystemEntry(path=path, change_type="added") for path in sorted(after.keys() - before.keys())]
129+
entries += [FilesystemEntry(path=path, change_type="deleted") for path in sorted(before.keys() - after.keys())]
130+
entries += [
131+
FilesystemEntry(path=path, change_type="modified")
132+
for path in sorted(before.keys() & after.keys())
133+
if before[path] != after[path]
134+
]
135+
return FilesystemDiff(entries=entries)
136+
137+
def _hashes(self) -> dict[str, str]:
138+
hashes: dict[str, str] = {}
139+
for rel in self._list_sync("**/*"):
140+
hashes[rel] = hashlib.sha256((self._root / rel).read_bytes()).hexdigest()
141+
return hashes
142+
143+
async def run_verifier(
144+
self,
145+
command: _StrList,
146+
*,
147+
cwd: str = ".",
148+
timeout_s: float | None = None,
149+
) -> CommandResult:
150+
"""Run ``command`` (no shell) against a throwaway copy of the evidence.
151+
152+
The evidence is copied to a temp overlay so the command can never mutate
153+
stored evidence (pytest caches, build artifacts, ...). ``command`` is a
154+
list passed straight to exec — no shell parsing, so no injection surface.
155+
"""
156+
overlay = Path(tempfile.mkdtemp(prefix="evidence-verify-")).resolve()
157+
try:
158+
workdir = (overlay / cwd).resolve()
159+
if workdir != overlay and overlay not in workdir.parents:
160+
raise ValueError(f"verifier cwd {cwd!r} resolves outside evidence overlay")
161+
await asyncio.to_thread(shutil.copytree, self._root, overlay, dirs_exist_ok=True)
162+
return await self._exec(command, workdir, timeout_s)
163+
finally:
164+
await asyncio.to_thread(shutil.rmtree, overlay, True)
165+
166+
@staticmethod
167+
async def _exec(command: _StrList, cwd: Path, timeout_s: float | None) -> CommandResult:
168+
process = await asyncio.create_subprocess_exec(
169+
*command,
170+
cwd=str(cwd),
171+
stdout=asyncio.subprocess.PIPE,
172+
stderr=asyncio.subprocess.PIPE,
173+
)
174+
try:
175+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout_s)
176+
except TimeoutError:
177+
# wait_for cancels communicate() but leaves the child running; kill it.
178+
process.kill()
179+
await process.wait()
180+
return CommandResult(exit_code=-1, timed_out=True)
181+
return CommandResult(
182+
exit_code=process.returncode if process.returncode is not None else -1,
183+
stdout=stdout.decode(errors="replace"),
184+
stderr=stderr.decode(errors="replace"),
185+
)
186+
56187

57188
class EvidenceDescriptor(BaseModel):
58189
"""Descriptor for a candidate trace, source, or artifact."""

packages/nemo_evaluator_sdk/tests/agent_eval/test_evidence.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55

66
import pytest
77
from nemo_evaluator_sdk.execution.samples import build_metric_input
8-
from nemo_evaluator_sdk.values.evidence import CandidateEvidence, EvidenceDescriptor
8+
from nemo_evaluator_sdk.values.evidence import (
9+
CandidateEvidence,
10+
EvidenceDescriptor,
11+
LocalFilesystemEvidence,
12+
)
913

1014

1115
def test_metric_input_preserves_candidate_evidence_out_of_metadata() -> None:
@@ -53,3 +57,52 @@ async def test_candidate_evidence_filesystem_access_is_lazy_and_cached(tmp_path:
5357
handle.path("../outside")
5458
with pytest.raises(ValueError, match="only supports local filesystem"):
5559
await evidence.filesystem("remote_state")
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_filesystem_read_bytes_list_and_diff(tmp_path: Path) -> None:
64+
before = tmp_path / "before"
65+
after = tmp_path / "after"
66+
for root in (before, after):
67+
(root / "src").mkdir(parents=True)
68+
(before / "keep.txt").write_text("same", encoding="utf-8")
69+
(before / "src" / "mod.py").write_text("old", encoding="utf-8")
70+
(before / "gone.txt").write_text("bye", encoding="utf-8")
71+
(after / "keep.txt").write_text("same", encoding="utf-8")
72+
(after / "src" / "mod.py").write_text("new", encoding="utf-8")
73+
(after / "added.txt").write_text("hi", encoding="utf-8")
74+
75+
initial = LocalFilesystemEvidence(before)
76+
final = LocalFilesystemEvidence(after)
77+
78+
assert await final.read_bytes("added.txt") == b"hi"
79+
assert await final.list("**/*.py") == ["src/mod.py"]
80+
81+
diff = await initial.diff(final)
82+
assert {(entry.path, entry.change_type) for entry in diff.entries} == {
83+
("added.txt", "added"),
84+
("gone.txt", "deleted"),
85+
("src/mod.py", "modified"),
86+
}
87+
assert [entry.path for entry in diff.changed(prefix="src/", kinds={"modified"})] == ["src/mod.py"]
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_run_verifier_uses_overlay_and_reports_outcome(tmp_path: Path) -> None:
92+
root = tmp_path / "workspace"
93+
root.mkdir()
94+
(root / "answer.txt").write_text("42", encoding="utf-8")
95+
handle = LocalFilesystemEvidence(root)
96+
97+
ok = await handle.run_verifier(["cat", "answer.txt"])
98+
assert ok.ok and ok.exit_code == 0 and ok.stdout.strip() == "42"
99+
100+
failed = await handle.run_verifier(["false"])
101+
assert not failed.ok and failed.exit_code != 0
102+
103+
timed_out = await handle.run_verifier(["sleep", "5"], timeout_s=0.2)
104+
assert timed_out.timed_out and not timed_out.ok
105+
106+
# The verifier ran in a throwaway copy, so the stored evidence is untouched.
107+
await handle.run_verifier(["sh", "-c", "echo cheat > sneaked.txt"])
108+
assert await handle.list("**/*") == ["answer.txt"]

0 commit comments

Comments
 (0)