Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:36:06.449Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

rejudge.py

360 lines · 13.8 KB · python
1"""Re-grade a finished run with a different judge, without redoing agent work.
2
3A rejudge takes the agent output already stored in a parent run (the expensive
4part: patches/answers the agent produced) and re-runs only the *judge* step with
5a new judge model, producing a brand-new derived run. The original run is read
6only and never modified.
7
8This is deliberately not a re-implementation of the grader. It runs the exact
9SWE-Atlas verifier script shipped in each task package (`evaluate_rubrics.py` for
10RF, `evaluate_answer.py` for QnA) through the same judge proxy shim the live
11in-sandbox path uses (`judge_proxy.JUDGE_PROXY_SCRIPT`), so a rejudged verdict is
12produced identically to a first-pass verdict — only the judge model differs.
13
14The verifier scripts read their inputs from fixed absolute paths (`/tests`,
15`/logs/...`) and pick the judge up from `EVAL_MODEL`/`EVAL_BASE_URL`/
16`EVAL_API_KEY`, so this module is meant to run inside a throwaway container (the
17Modal `rejudge_controller`), where staging those absolute paths is safe. The pure
18helpers (resolution, reward recombination, result patching) have no such
19requirement and are unit-tested directly.
20"""
21
22from __future__ import annotations
23
24import json
25import os
26import shutil
27import socket
28import subprocess
29import sys
30import time
31from pathlib import Path
32from typing import Any
33
34from . import config
35from .judge_proxy import JUDGE_PROXY_SCRIPT
36
37# Each task package ships exactly one of these verifier scripts; its presence is
38# how we tell an RF task from a QnA one without trusting the experiment config.
39RF_VERIFIER = "evaluate_rubrics.py"
40QNA_VERIFIER = "evaluate_answer.py"
41
42PART_RF = "rf"
43PART_QNA = "qna"
44
45# Absolute paths the cached verifier scripts hardcode. Staged per trial.
46TESTS_DIR = Path("/tests")
47LOGS_AGENT_DIR = Path("/logs/agent")
48LOGS_VERIFIER_DIR = Path("/logs/verifier")
49RF_PATCH_PATH = LOGS_VERIFIER_DIR / "agent.patch"
50RF_RESULTS_PATH = LOGS_VERIFIER_DIR / "rubrics_results.json"
51QNA_ANSWER_PATH = LOGS_AGENT_DIR / "answer.txt"
52QNA_RESULTS_PATH = LOGS_VERIFIER_DIR / "evaluation_results.json"
53
54
55def load_json(path: Path) -> Any:
56    try:
57        return json.loads(Path(path).read_text())
58    except (OSError, json.JSONDecodeError):
59        return None
60
61
62def task_tests_dir(task_name: str, task_ref: str, tasks_root: Path) -> Path:
63    """Resolve a trial's cached task package `tests/` dir from its name + ref.
64
65    Mirrors Harbor's content-addressed layout:
66    `<tasks_root>/<org>/<task-id>/<ref-hash>/tests`.
67    """
68    org, task_id = task_name.split("/", 1)
69    ref_hash = task_ref.split(":", 1)[1] if ":" in task_ref else task_ref
70    return tasks_root / org / task_id / ref_hash / "tests"
71
72
73def tests_dir_for_trial(trial_dir: Path, tasks_root: Path) -> Path | None:
74    config_data = load_json(trial_dir / "config.json")
75    if not isinstance(config_data, dict):
76        return None
77    task = config_data.get("task") or {}
78    name, ref = task.get("name"), task.get("ref")
79    if not (name and ref):
80        return None
81    tests_dir = task_tests_dir(name, ref, tasks_root)
82    return tests_dir if tests_dir.is_dir() else None
83
84
85def detect_part(tests_dir: Path) -> str | None:
86    if (tests_dir / RF_VERIFIER).exists():
87        return PART_RF
88    if (tests_dir / QNA_VERIFIER).exists():
89        return PART_QNA
90    return None
91
92
93def recompute_rf_rewards(
94    rubrics_results: dict[str, Any], prior_rewards: dict[str, Any]
95) -> dict[str, float]:
96    """Combine the freshly re-judged rubric verdict with the *unchanged* saved
97    deterministic test reward. The agent patch didn't change, so `tests_reward`
98    is invariant under rejudge; only the rubric (`must_have_pass`/agg) moves.
99
100    The overall RF reward is `must_have_pass AND tests_reward == 1`, verified to
101    hold across every cached RF trial.
102    """
103    must_have_pass = bool(rubrics_results.get("must_have_pass"))
104    agg_score = rubrics_results.get("agg_score")
105    tests_reward = float(prior_rewards.get("tests_reward") or 0.0)
106    overall_pass = 1.0 if (must_have_pass and tests_reward >= 1.0) else 0.0
107    return {
108        "reward": overall_pass,
109        "overall_pass": overall_pass,
110        "tests_reward": tests_reward,
111        "must_have_pass": 1.0 if must_have_pass else 0.0,
112        "rubrics_agg_score": float(agg_score) if agg_score is not None else 0.0,
113    }
114
115
116def recompute_qna_rewards(evaluation_results: dict[str, Any]) -> dict[str, float]:
117    """QnA has no deterministic test component; the reward is just the judged
118    pass verdict, matching the single-key `{"reward": ...}` production shape."""
119    return {"reward": 1.0 if bool(evaluation_results.get("pass")) else 0.0}
120
121
122def proxy_environment(judge: config.JudgeConfig) -> dict[str, str]:
123    """Env the judge proxy subprocess reads. The auth key it forwards
124    (`judge.auth_env`, e.g. BASETEN_API_KEY) must already be present in the
125    process environment via the mounted LLM-providers secret."""
126    env = {
127        "ZED_JUDGE_UPSTREAM": judge.upstream,
128        "ZED_JUDGE_AUTH_ENV": judge.auth_env,
129    }
130    if judge.max_tokens is not None:
131        env["ZED_JUDGE_MAX_TOKENS"] = str(judge.max_tokens)
132    return env
133
134
135def verifier_environment(judge_model: str, port: int) -> dict[str, str]:
136    """Env the cached verifier reads to find the judge.
137
138    This mirrors the verifier env injected by the benchmark harness command,
139    pointed at the local rejudge proxy.
140    """
141    return {
142        "EVAL_MODEL": judge_model,
143        "EVAL_BASE_URL": f"http://127.0.0.1:{port}/v1",
144        "EVAL_API_KEY": "unused-by-agent-evals-proxy",
145    }
146
147
148class JudgeProxy:
149    """Runs `judge_proxy.JUDGE_PROXY_SCRIPT` as a local subprocess for the life
150    of a rejudge job. One proxy serves every trial (they all target the same
151    judge), mirroring the single in-sandbox daemon."""
152
153    def __init__(self, judge: config.JudgeConfig, port: int = 8089) -> None:
154        self._judge = judge
155        self._port = port
156        self._process: subprocess.Popen[bytes] | None = None
157        self._script_path = Path("/tmp/zed_judge_proxy.py")
158
159    @property
160    def port(self) -> int:
161        return self._port
162
163    def __enter__(self) -> JudgeProxy:
164        self._script_path.write_text(JUDGE_PROXY_SCRIPT)
165        env = os.environ.copy()
166        env.update(proxy_environment(self._judge))
167        env["ZED_JUDGE_PROXY_PORT"] = str(self._port)
168        self._process = subprocess.Popen(
169            [sys.executable, str(self._script_path)],
170            env=env,
171            stdout=subprocess.DEVNULL,
172            stderr=subprocess.DEVNULL,
173        )
174        self._wait_for_port()
175        return self
176
177    def __exit__(self, *_exc: object) -> None:
178        if self._process is not None:
179            self._process.terminate()
180            try:
181                self._process.wait(timeout=10)
182            except subprocess.TimeoutExpired:
183                self._process.kill()
184
185    def _wait_for_port(self, timeout_secs: float = 30.0) -> None:
186        deadline = time.time() + timeout_secs
187        while time.time() < deadline:
188            if self._process is not None and self._process.poll() is not None:
189                raise RuntimeError("judge proxy exited before becoming ready")
190            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
191                probe.settimeout(0.5)
192                if probe.connect_ex(("127.0.0.1", self._port)) == 0:
193                    return
194            time.sleep(0.25)
195        raise TimeoutError(f"judge proxy did not open port {self._port} in time")
196
197
198def _stage_tests_dir(tests_dir: Path) -> None:
199    """Point the absolute `/tests` path at this trial's package. Reset per trial
200    so each verifier reads its own rubrics/prompts."""
201    if TESTS_DIR.is_symlink() or TESTS_DIR.exists():
202        if TESTS_DIR.is_dir() and not TESTS_DIR.is_symlink():
203            shutil.rmtree(TESTS_DIR)
204        else:
205            TESTS_DIR.unlink()
206    TESTS_DIR.symlink_to(tests_dir)
207
208
209def _run_verifier(
210    part: str, tests_dir: Path, judge_model: str, port: int
211) -> dict[str, Any]:
212    """Stage the verifier's expected inputs and run the real cached script."""
213    LOGS_AGENT_DIR.mkdir(parents=True, exist_ok=True)
214    LOGS_VERIFIER_DIR.mkdir(parents=True, exist_ok=True)
215    _stage_tests_dir(tests_dir)
216
217    env = os.environ.copy()
218    env.update(verifier_environment(judge_model, port))
219    script = tests_dir / (RF_VERIFIER if part == PART_RF else QNA_VERIFIER)
220    results_path = RF_RESULTS_PATH if part == PART_RF else QNA_RESULTS_PATH
221    if results_path.exists():
222        results_path.unlink()
223
224    completed = subprocess.run(
225        [sys.executable, str(script)],
226        env=env,
227        capture_output=True,
228        text=True,
229    )
230    results = load_json(results_path)
231    if not isinstance(results, dict):
232        raise RuntimeError(
233            f"verifier produced no results (exit {completed.returncode}): "
234            f"{completed.stderr.strip()[:500]}"
235        )
236    return results
237
238
239def rejudge_trial(
240    trial_dir: Path,
241    tasks_root: Path,
242    judge: config.JudgeConfig,
243    judge_model: str,
244    port: int,
245) -> dict[str, Any]:
246    """Re-grade one trial in place (the trial dir is a copy in the derived run).
247
248    Returns a per-trial record; on any recoverable problem it returns
249    `{"ok": False, "error": ...}` so one bad trial can't sink the whole job.
250    """
251    task_name = trial_dir.name
252    tests_dir = tests_dir_for_trial(trial_dir, tasks_root)
253    if tests_dir is None:
254        return {"task": task_name, "ok": False, "error": "task package not found"}
255    part = detect_part(tests_dir)
256    if part is None:
257        return {"task": task_name, "ok": False, "error": "no verifier in task package"}
258
259    # The agent output is already on disk from the parent run; copy it to the
260    # path the verifier expects. RF reads the patch, QnA the delivered answer.
261    if part == PART_RF:
262        source = trial_dir / "verifier" / "agent.patch"
263        if not source.exists():
264            source = trial_dir / "verifier" / "agent_source_only.patch"
265        if not source.exists():
266            return {"task": task_name, "ok": False, "error": "no agent.patch"}
267        RF_PATCH_PATH.parent.mkdir(parents=True, exist_ok=True)
268        shutil.copyfile(source, RF_PATCH_PATH)
269    else:
270        source = trial_dir / "agent" / "answer.txt"
271        if not source.exists():
272            return {"task": task_name, "ok": False, "error": "no answer.txt"}
273        QNA_ANSWER_PATH.parent.mkdir(parents=True, exist_ok=True)
274        shutil.copyfile(source, QNA_ANSWER_PATH)
275
276    try:
277        results = _run_verifier(part, tests_dir, judge_model, port)
278    except (RuntimeError, OSError) as error:
279        return {"task": task_name, "ok": False, "error": str(error)}
280
281    prior_rewards = (
282        (load_json(trial_dir / "result.json") or {}).get("verifier_result") or {}
283    ).get("rewards") or {}
284    if part == PART_RF:
285        new_rewards = recompute_rf_rewards(results, prior_rewards)
286        verifier_results_name = "rubrics_results.json"
287    else:
288        new_rewards = recompute_qna_rewards(results)
289        verifier_results_name = "evaluation_results.json"
290
291    # Persist the fresh verifier output and verdict into the derived trial dir so
292    # report.py reads the new judge's result with no special-casing.
293    (trial_dir / "verifier").mkdir(parents=True, exist_ok=True)
294    (trial_dir / "verifier" / verifier_results_name).write_text(
295        json.dumps(results, indent=2)
296    )
297    patch_result_rewards(trial_dir / "result.json", new_rewards)
298    return {"task": task_name, "ok": True, "part": part, "rewards": new_rewards}
299
300
301def patch_result_rewards(result_path: Path, new_rewards: dict[str, float]) -> None:
302    """Overwrite only `verifier_result.rewards` in a trial's `result.json`,
303    leaving the agent's resource metrics (tokens/steps/tool calls) untouched —
304    the agent didn't re-run, only the judge did."""
305    result = load_json(result_path)
306    if not isinstance(result, dict):
307        result = {}
308    verifier_result = result.get("verifier_result")
309    if not isinstance(verifier_result, dict):
310        verifier_result = {}
311    verifier_result["rewards"] = new_rewards
312    result["verifier_result"] = verifier_result
313    Path(result_path).write_text(json.dumps(result, indent=2))
314
315
316def rejudge_job(
317    parent_job_dir: Path,
318    out_job_dir: Path,
319    tasks_root: Path,
320    judge: config.JudgeConfig,
321    judge_model: str,
322    port: int = 8089,
323    log: Any = print,
324) -> dict[str, Any]:
325    """Copy a parent run's Harbor job dir into the derived run and re-judge every
326    trial in place. Returns a summary suitable for the run's `summary.json`."""
327    if out_job_dir.exists():
328        shutil.rmtree(out_job_dir)
329    shutil.copytree(parent_job_dir, out_job_dir)
330
331    trial_dirs = sorted(
332        path
333        for path in out_job_dir.iterdir()
334        if path.is_dir() and (path / "result.json").exists()
335    )
336    records: list[dict[str, Any]] = []
337    with JudgeProxy(judge, port=port) as proxy:
338        for index, trial_dir in enumerate(trial_dirs, start=1):
339            record = rejudge_trial(
340                trial_dir, tasks_root, judge, judge_model, proxy.port
341            )
342            records.append(record)
343            status = "ok" if record["ok"] else f"FAILED: {record.get('error')}"
344            log(f"[{index}/{len(trial_dirs)}] {trial_dir.name}: {status}")
345
346    rejudged = sum(1 for record in records if record["ok"])
347    passed = sum(
348        1
349        for record in records
350        if record["ok"] and record["rewards"].get("reward", 0.0) >= 1.0
351    )
352    return {
353        "trial_count": len(trial_dirs),
354        "rejudged_count": rejudged,
355        "failed_count": len(trial_dirs) - rejudged,
356        "passed_count": passed,
357        "judge_model": judge_model,
358        "trials": records,
359    }
360
Served at tenant.openagents/omega Member data and write actions are omitted.