Skip to content

Graphrl - #107

Open
JamesKrW wants to merge 5 commits into
mll-lab-nu:mainfrom
JamesKrW:graphrl
Open

Graphrl#107
JamesKrW wants to merge 5 commits into
mll-lab-nu:mainfrom
JamesKrW:graphrl

Conversation

@JamesKrW

@JamesKrW JamesKrW commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Multi-process evaluation with configurable worker count and per-worker concurrency
    • Best-validation checkpoint tracking and automatic best-model saving
    • Labeled Hugging Face uploads and on-demand "best-val" upload trigger
  • Bug Fixes / Reliability

    • Round-robin job splitting, robust aggregation, and structured error records for crashed workers
    • More reliable resume/rollout inspection and safer cleanup using explicit finish-reasons and stricter metadata handling

Review Change Stack

JamesKrW and others added 2 commits May 2, 2026 05:57
Single-process asyncio rollout in run_eval_parallel becomes the bottleneck
when per-job CPU work is heavy (PIL image load + resize, base64 encoding,
JSON serialization, regex parsing in vision-language reasoning loops): the
GPU-side scheduler sits idle waiting for the orchestrator to dispatch
requests because Python is GIL-bound to one core.

Add an opt-in multi-process mode: when run.num_workers > 1, run_eval.main
partitions jobs round-robin across N subprocess workers. Each worker runs
its own asyncio event loop calling the unmodified run_eval_parallel against
the same shared backend. The dump_dir layout is bit-identical to the
single-process path because every rollout writes to its own
tag_<id>/<seed>/ subdir — concurrent writers never collide.

Default num_workers=1 → legacy single-process path, byte-identical behaviour.
max_concurrent_jobs is now per-worker (so existing configs without
num_workers are unchanged). Total in-flight = num_workers * max_concurrent_jobs.

Also adds run.normal_finish_reasons override so callers (e.g. the reasoning-
augmentation pipeline that monkey-patches NORMAL_FINISH_REASONS to {"done"})
can propagate that override across the spawn boundary into child workers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR introduces multi-process evaluation for VAGEN by adding job partitioning, subprocess worker infrastructure, and configurable routing in the main evaluation entry point. It also adds best-validation checkpoint tracking and HuggingFace upload integration to save and optionally upload the best actor checkpoint.

Changes

Multi-process Evaluation Feature

Layer / File(s) Summary
Module Imports
vagen/evaluate/run_eval.py
Expand imports to include math and import runner as _runner_mod for parent/child finish-reason synchronization.
Rollout Helpers
vagen/evaluate/run_eval.py
Add best-effort JSON loader, rollout directory iterator, and finish-reason derivation (maps terminated+success → \"done\").
Resume: Purge & Collect
vagen/evaluate/run_eval.py
Refactor _purge_error_rollouts() and _collect_completed_runs() to use new helpers, prefer meta.json when not None, normalize completion keys, and consult _runner_mod.NORMAL_FINISH_REASONS.
Worker Entry & Chunking
vagen/evaluate/runner.py
Add run_eval_chunk_subprocess(payload) subprocess entrypoint that re-registers builtin envs and applies parent normal_finish_reasons override; add split_jobs_round_robin() to partition jobs round-robin into up to num_workers chunks.
Multiprocess Orchestration
vagen/evaluate/run_eval.py
Add _run_jobs_multiprocess() to round-robin partition jobs, ceil-divide concurrency per worker, spawn ProcessPoolExecutor with spawn start method, submit run_eval_chunk_subprocess payloads, aggregate worker results, and emit per-job error records for crashed chunks.
Main Configuration & Routing
vagen/evaluate/run_eval.py
main() reads run.num_workers and optional run.normal_finish_reasons; patches _runner_mod.NORMAL_FINISH_REASONS when overridden; routes to single-process asyncio.run(run_eval_parallel(...)) or multi-process _run_jobs_multiprocess() and passes overrides into worker payloads.

Best-Validation Checkpointing & HF Uploads

Layer / File(s) Summary
Config
vagen/configs/vagen_multiturn.yaml
Enable trainer.save_best_val: True and change huggingface_hub.upload_contents to ['actor/huggingface/**'].
BestValTracker
vagen/utils/best_val.py
New BestValTracker class: aggregate val-core/ metrics, track best score/step, save pruned best_val/actor/huggingface/, write best_val/best_val.json, and trigger HF upload.
Trainer Wiring
vagen/ray_trainer.py
Import and initialize BestValTracker and call maybe_save(...) during validation (no-op unless enabled).
HF Upload API
vagen/utils/upload_hugging_face.py
Change HFUploadActor.upload to use label: str; treat upload_contents as raw glob patterns; add HFUploadManager.maybe_upload_best_val(...); use labeled uploads for per-step and best-val triggers.

Sequence Diagram(s)

sequenceDiagram
  participant ParentProcess
  participant ProcessPoolExecutor
  participant WorkerProcess
  ParentProcess->>ProcessPoolExecutor: submit(run_eval_chunk_subprocess, payload(chunk, concurrency, normal_finish_reasons))
  ProcessPoolExecutor->>WorkerProcess: spawn worker (spawn start method)
  WorkerProcess->>WorkerProcess: apply normal_finish_reasons override
  WorkerProcess->>WorkerProcess: re-register builtin envs
  WorkerProcess->>WorkerProcess: asyncio.run(run_eval_parallel(...)) -> returns rollout records
  WorkerProcess-->>ProcessPoolExecutor: return rollout records
  ProcessPoolExecutor-->>ParentProcess: aggregated results or worker crash
  ParentProcess->>ParentProcess: convert crashed chunk -> per-job error records
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • mll-lab-nu/VAGEN#96: Related changes to the HuggingFace upload pipeline / HFUploadActor initialization.

Poem

🐰 I split the jobs in merry rows,

Workers spawn where parallel grows,
Best-val keeps the finest seed,
HuggingFace saves what we need,
Hopping checks to ship with speed.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Graphrl' is a vague, non-descriptive term that does not convey meaningful information about the changeset. The PR actually implements multiprocess evaluation, best validation checkpoint tracking, and HuggingFace upload refactoring—none of which are reflected in the title. Provide a descriptive title that summarizes the main changes, such as 'Add multiprocess evaluation and best validation checkpoint tracking' or 'Implement parallel evaluation with best-val optimization.'
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@vagen/evaluate/run_eval.py`:
- Around line 422-437: The payloads currently pass global concurrency settings
unchanged (max_concurrent_jobs and backend_cfg["max_concurrency"]) to each
worker, which multiplies backend load by num_workers; modify the construction of
worker_payloads so these limits are converted to per-worker limits (e.g.,
per_worker_max_concurrency = max(1, floor_div(global_max_concurrency,
num_workers)) or use ceil as appropriate) and set "max_concurrent_jobs" and
backend_cfg["max_concurrency"] in each payload to those per-worker values before
appending, or alternatively rename and document the fields as per-worker limits
if that behavior is intended; locate this logic around the worker_payloads loop
and the variables max_concurrent_jobs and backend_cfg to implement the change.
- Around line 492-495: The module-level constant NORMAL_FINISH_REASONS (imported
by value earlier) must be updated when run_cfg provides an override; change the
override block so after setting _runner_mod.NORMAL_FINISH_REASONS you also
assign this module's NORMAL_FINISH_REASONS = set(nfr_override) so
_purge_error_rollouts() and _collect_completed_runs() (which use the local name)
see the new set; i.e., update the local binding in addition to the runner
module's binding.
- Around line 452-454: run_eval_parallel currently uses
ProcessPoolExecutor.pool.map which will abort the whole batch if any worker
raises (bootstrap/pickling/import errors); change to submit each worker_payload
with pool.submit(run_eval_chunk_subprocess, payload) and iterate futures via
concurrent.futures.as_completed, calling future.result() inside a try/except so
you can convert worker exceptions into the same structured per-job error records
returned by run_eval_parallel; include identifying context from the
corresponding worker_payload (e.g., payload id or dataset info) in the error
record so other successful chunk_results are still collected into results and a
failing worker doesn't abort the batch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 27b2020d-f21b-4c5b-969d-b13b5eddf335

📥 Commits

Reviewing files that changed from the base of the PR and between 2f83831 and aeae1a1.

📒 Files selected for processing (2)
  • vagen/evaluate/run_eval.py
  • vagen/evaluate/runner.py

Comment thread vagen/evaluate/run_eval.py Outdated
Comment thread vagen/evaluate/run_eval.py Outdated
Comment thread vagen/evaluate/run_eval.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
vagen/evaluate/run_eval.py (1)

506-506: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Type annotation drifts from _collect_completed_runs return type.

_collect_completed_runs returns Dict[Tuple[str, int, Union[int, str]], str] (tag_id may be a string), but the local annotation here pins tag_id to int. Tighten or unify with the helper's signature; otherwise type checkers will flag every assignment from _collect_completed_runs and any future str-keyed lookup against completed_index here.

🩹 Suggested fix
-    completed_index: Dict[Tuple[str, int, int], str] = {}
+    completed_index: Dict[Tuple[str, int, Union[int, str]], str] = {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vagen/evaluate/run_eval.py` at line 506, The local variable completed_index
is annotated too narrowly compared to the helper _collect_completed_runs (which
returns Dict[Tuple[str,int,Union[int,str]], str]); update completed_index's
annotation to match that return type (or reuse the helper's type alias/return
annotation) so the tuple key allows tag_id to be int or str and avoids
type-checker errors when assigning or looking up values; ensure you import
typing.Union if needed and adjust any other references to completed_index
accordingly.
🧹 Nitpick comments (1)
vagen/evaluate/run_eval.py (1)

229-243: 💤 Low value

Inline _pick to silence Ruff B023 and avoid per-iteration closure churn.

_pick captures the loop-scope meta/metrics (Ruff B023). It's safe today because it's invoked immediately within the same iteration, but the pattern is brittle (any future hoist of the call breaks it) and creates a new function object every iteration.

♻️ Suggested refactor
         meta = _read_json(os.path.join(rollout.path, "meta.json")) or {}
-        # Prefer meta.json over metrics.json, but fall back only when meta's
-        # value is missing/None — NOT when it's a falsy-but-valid 0.
-        def _pick(key: str) -> Any:
-            v = meta.get(key)
-            return v if v is not None else metrics.get(key)
-        env_name, seed, tag_id = _pick("env_name"), _pick("seed"), _pick("tag_id")
+        # Prefer meta.json over metrics.json, but fall back only when meta's
+        # value is missing/None — NOT when it's a falsy-but-valid 0.
+        def _pick(src_meta: Dict[str, Any], src_metrics: Dict[str, Any], key: str) -> Any:
+            v = src_meta.get(key)
+            return v if v is not None else src_metrics.get(key)
+        env_name = _pick(meta, metrics, "env_name")
+        seed = _pick(meta, metrics, "seed")
+        tag_id = _pick(meta, metrics, "tag_id")

Or define _pick once at module scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vagen/evaluate/run_eval.py` around lines 229 - 243, The helper function _pick
defined inside the loop captures loop-scoped variables meta/metrics and
allocates a new function every iteration (Ruff B023); either inline its logic
where it's used (replace calls to _pick("key") with the two-line lookup v =
meta.get(key); use v if v is not None else metrics.get(key)) or hoist a single
reusable helper out of the loop (define _pick once at module scope so it doesn't
close over per-iteration state); update the uses that set env_name, seed, tag_id
and leave completed[(str(env_name), int(seed), tag_id)] logic unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@vagen/evaluate/run_eval.py`:
- Around line 377-454: The multi-process workers can concurrently write the same
tag summary file causing corruption; to fix, when _run_jobs_multiprocess sees
live_summary is True and num_workers > 1, automatically disable live_summary for
child workers and log a warning, i.e. before building payload_base set
live_summary=False (and keep the original live_summary for a single-process
final summary), update payload_base used by run_eval_chunk_subprocess so child
processes do not call write_rollouts_summary_from_dump concurrently;
alternatively (optional) implement partitioning by tag_id (e.g., replace
split_jobs_round_robin with a split_jobs_by_tag_id path when live_summary is
requested) so each worker owns disjoint tag directories.

---

Outside diff comments:
In `@vagen/evaluate/run_eval.py`:
- Line 506: The local variable completed_index is annotated too narrowly
compared to the helper _collect_completed_runs (which returns
Dict[Tuple[str,int,Union[int,str]], str]); update completed_index's annotation
to match that return type (or reuse the helper's type alias/return annotation)
so the tuple key allows tag_id to be int or str and avoids type-checker errors
when assigning or looking up values; ensure you import typing.Union if needed
and adjust any other references to completed_index accordingly.

---

Nitpick comments:
In `@vagen/evaluate/run_eval.py`:
- Around line 229-243: The helper function _pick defined inside the loop
captures loop-scoped variables meta/metrics and allocates a new function every
iteration (Ruff B023); either inline its logic where it's used (replace calls to
_pick("key") with the two-line lookup v = meta.get(key); use v if v is not None
else metrics.get(key)) or hoist a single reusable helper out of the loop (define
_pick once at module scope so it doesn't close over per-iteration state); update
the uses that set env_name, seed, tag_id and leave completed[(str(env_name),
int(seed), tag_id)] logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 37ec3ba0-12c6-40c7-a4a9-0ac21d9c5239

📥 Commits

Reviewing files that changed from the base of the PR and between aeae1a1 and dfcdf2e.

📒 Files selected for processing (2)
  • vagen/evaluate/run_eval.py
  • vagen/evaluate/runner.py

Comment thread vagen/evaluate/run_eval.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
vagen/utils/upload_hugging_face.py (2)

84-86: 💤 Low value

Narrow the exception class on upload.

Static analysis flagged the bare except Exception: (Ruff BLE001). The intent here is clearly "don't crash the training loop on HF transport errors," which is reasonable, but catching Exception will also swallow programmer errors (e.g., AttributeError, TypeError) silently. Consider narrowing to the HF-specific surface, e.g. huggingface_hub.errors.HfHubHTTPError plus OSError/requests.RequestException, and re-raising KeyboardInterrupt/SystemExit implicitly by being more specific.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vagen/utils/upload_hugging_face.py` around lines 84 - 86, The bare except in
the upload handler should be narrowed to HF/transport errors so programmer
errors aren't swallowed: replace "except Exception as e:" in the upload method
(the block referencing self.repo_id and label) with a specific except that
catches huggingface_hub.errors.HfHubHTTPError plus network/IO exceptions (e.g.,
requests.RequestException and OSError), log the error the same way and return
None; add the necessary imports (HfHubHTTPError and requests.RequestException)
at the top of the module and do not catch KeyboardInterrupt/SystemExit so they
propagate.

122-147: 💤 Low value

Validation logic only runs when hf_save_freq is set, but upload_contents is also used by best-val uploads.

The isinstance check on _upload_contents and the actor_kwargs/repo_id setup on lines 140–165 are guarded by if not self._hf_save_freq: return. That means when a user configures save_best_val: True and provides huggingface_hub.upload_contents/repo_id but leaves hf_save_freq unset, the actor is never created, so maybe_upload_best_val will silently no-op (because enabled is False).

This is consistent with the docstring ("No-op if HF upload is disabled"), but it means best-val HF upload is implicitly gated on hf_save_freq rather than its own flag. Worth either:

  • documenting this coupling explicitly in vagen_multiturn.yaml near hf_save_freq, or
  • decoupling best-val upload by allowing the actor to be created whenever repo_id is set.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vagen/utils/upload_hugging_face.py` around lines 122 - 147, The init
currently bails out early when self._hf_save_freq is falsy which skips
validation of self._upload_contents and creation of the HF actor, so best-val
uploads (triggered by save_best_val/repo_id) silently no-op; change the logic in
__init__ so that only hf-save-frequency-specific setup is skipped when
_hf_save_freq is falsy, but still validate upload_contents (the isinstance check
for self._upload_contents) and perform the actor/actor_kwargs/repo_id setup
whenever a repo_id is configured or save_best_val is enabled (so
maybe_upload_best_val can work independently); update the guards around the
blocks that reference actor_kwargs, repo_id, and actor creation to check for
repo_id or save_best_val instead of requiring _hf_save_freq.
vagen/utils/best_val.py (2)

99-115: ⚡ Quick win

Destructive rmtree before save risks losing the previous best on save failure.

The order is: flush() → rmtree(best_val_root) → save_checkpoint(...) → prune → write_metadata → upload. If save_checkpoint raises (OOM, FSDP rank failure, disk full), the previous best — both on disk and remotely if it was already uploaded locally — is gone and self.best_score has already been bumped (lines 92–93). The next validation would not save anything until a new high score appears.

Safer pattern: save into a temporary sibling directory and only swap on success, or at least defer self.best_score/self.best_step updates until after save_checkpoint returns.

🛡️ Minimal: defer state update until after save succeeds
-        prev = self.best_score
-        self.best_score = score
-        self.best_step = global_steps
-        print(
-            f"[BestVal] New best validation score {score:.6f} at step "
-            f"{global_steps} (previous: {prev})."
-        )
-
         best_val_root = os.path.join(self.default_local_dir, "best_val")
         actor_local_path = os.path.join(best_val_root, "actor")

         # Flush any in-flight HF upload before mutating best_val/.
         hf_upload_manager.flush()

-        # Wipe stale best_val from the previous best step so only the new
-        # HF model survives the prune step below.
-        if os.path.isdir(best_val_root):
-            shutil.rmtree(best_val_root, ignore_errors=True)
-
-        actor_rollout_wg.save_checkpoint(
-            actor_local_path, None, global_steps, max_ckpt_to_keep=None
-        )
-        _prune_to_huggingface(actor_local_path)
-        _write_metadata(best_val_root, global_steps, score)
-        hf_upload_manager.maybe_upload_best_val(global_steps, best_val_root)
+        staging_root = best_val_root + ".tmp"
+        if os.path.isdir(staging_root):
+            shutil.rmtree(staging_root, ignore_errors=True)
+        actor_rollout_wg.save_checkpoint(
+            os.path.join(staging_root, "actor"), None, global_steps, max_ckpt_to_keep=None
+        )
+        _prune_to_huggingface(os.path.join(staging_root, "actor"))
+        _write_metadata(staging_root, global_steps, score)
+        if os.path.isdir(best_val_root):
+            shutil.rmtree(best_val_root, ignore_errors=True)
+        os.replace(staging_root, best_val_root)
+
+        prev = self.best_score
+        self.best_score = score
+        self.best_step = global_steps
+        print(
+            f"[BestVal] New best validation score {score:.6f} at step "
+            f"{global_steps} (previous: {prev})."
+        )
+        hf_upload_manager.maybe_upload_best_val(global_steps, best_val_root)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vagen/utils/best_val.py` around lines 99 - 115, The current flow flush() →
rmtree(best_val_root) → save_checkpoint(...) risks losing the previous best if
actor_rollout_wg.save_checkpoint fails; change to save into a temporary sibling
directory (e.g., best_val_tmp) or keep the existing directory until
save_checkpoint completes, then atomically swap/rename the temp dir into
best_val_root and only call shutil.rmtree on the old dir after success;
additionally defer updating self.best_score/self.best_step (the updates around
lines 92–93) until after save_checkpoint, _prune_to_huggingface,
_write_metadata, and hf_upload_manager.maybe_upload_best_val complete
successfully so state only advances on a confirmed persisted upload.

139-145: 💤 Low value

Silently swallowing OSError on metadata write hides a bug.

If best_val/best_val.json fails to write, future restarts cannot rehydrate best_score/best_step, which is the same problem flagged on the constructor. Logging the exception (still without raising) would make this debuggable.

 def _write_metadata(best_val_root: str, global_steps: int, score: float) -> None:
     """Stamp ``best_val/best_val.json`` so consumers know which step won."""
     try:
         with open(os.path.join(best_val_root, "best_val.json"), "w") as f:
             json.dump({"step": global_steps, "score": score}, f, indent=2)
-    except OSError:
-        pass
+    except OSError as e:
+        print(f"[BestVal] Warning: failed to write best_val.json: {e}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vagen/utils/best_val.py` around lines 139 - 145, The current _write_metadata
function swallows OSError silently when writing best_val.json; change the except
OSError: block to except OSError as e: and log the failure (including the
exception details) instead of ignoring it — e.g., get a module logger
(logging.getLogger(__name__)) and call logger.error or logger.exception with
context like f"Failed to write {os.path.join(best_val_root, 'best_val.json')}
for step {global_steps}" and include exc_info so the error is recorded, but do
not re-raise (keep current behavior of not crashing).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@vagen/utils/best_val.py`:
- Around line 46-62: The current aggregate_val_core_score implementation should
ignore booleans and NaNs and optionally allow selecting a single metric name
instead of naïve averaging: update aggregate_val_core_score to accept an
optional best_val_metric (or read trainer.best_val_metric) and if set, try to
return that specific key's numeric value (only if found and not bool/NaN);
otherwise filter val_metrics items with k.startswith("val-core/") and include
only numbers where not isinstance(v, bool) and not math.isnan(float(v)), then
return None if no valid values or the mean of the remaining values; ensure
callers that compare to self.best_score get None for no-valid-metrics so NaNs
never propagate.
- Around line 37-41: In __init__ of the BestVal helper (the constructor shown),
load and parse the existing best_val.json from the trainer default_local_dir
(self.default_local_dir) when save_best_val is enabled, and set self.best_score
and self.best_step from that file instead of leaving them as None; handle the
file not existing or parse errors gracefully (leave None) and ensure the same
path/name used elsewhere for writing best_val is used when reading so resumed
runs correctly rehydrate prior state.

---

Nitpick comments:
In `@vagen/utils/best_val.py`:
- Around line 99-115: The current flow flush() → rmtree(best_val_root) →
save_checkpoint(...) risks losing the previous best if
actor_rollout_wg.save_checkpoint fails; change to save into a temporary sibling
directory (e.g., best_val_tmp) or keep the existing directory until
save_checkpoint completes, then atomically swap/rename the temp dir into
best_val_root and only call shutil.rmtree on the old dir after success;
additionally defer updating self.best_score/self.best_step (the updates around
lines 92–93) until after save_checkpoint, _prune_to_huggingface,
_write_metadata, and hf_upload_manager.maybe_upload_best_val complete
successfully so state only advances on a confirmed persisted upload.
- Around line 139-145: The current _write_metadata function swallows OSError
silently when writing best_val.json; change the except OSError: block to except
OSError as e: and log the failure (including the exception details) instead of
ignoring it — e.g., get a module logger (logging.getLogger(__name__)) and call
logger.error or logger.exception with context like f"Failed to write
{os.path.join(best_val_root, 'best_val.json')} for step {global_steps}" and
include exc_info so the error is recorded, but do not re-raise (keep current
behavior of not crashing).

In `@vagen/utils/upload_hugging_face.py`:
- Around line 84-86: The bare except in the upload handler should be narrowed to
HF/transport errors so programmer errors aren't swallowed: replace "except
Exception as e:" in the upload method (the block referencing self.repo_id and
label) with a specific except that catches huggingface_hub.errors.HfHubHTTPError
plus network/IO exceptions (e.g., requests.RequestException and OSError), log
the error the same way and return None; add the necessary imports
(HfHubHTTPError and requests.RequestException) at the top of the module and do
not catch KeyboardInterrupt/SystemExit so they propagate.
- Around line 122-147: The init currently bails out early when
self._hf_save_freq is falsy which skips validation of self._upload_contents and
creation of the HF actor, so best-val uploads (triggered by
save_best_val/repo_id) silently no-op; change the logic in __init__ so that only
hf-save-frequency-specific setup is skipped when _hf_save_freq is falsy, but
still validate upload_contents (the isinstance check for self._upload_contents)
and perform the actor/actor_kwargs/repo_id setup whenever a repo_id is
configured or save_best_val is enabled (so maybe_upload_best_val can work
independently); update the guards around the blocks that reference actor_kwargs,
repo_id, and actor creation to check for repo_id or save_best_val instead of
requiring _hf_save_freq.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 84ada0e3-e8a1-4346-8df0-e8ad6791d7e5

📥 Commits

Reviewing files that changed from the base of the PR and between dfcdf2e and 69d5c33.

📒 Files selected for processing (5)
  • vagen/configs/vagen_multiturn.yaml
  • vagen/evaluate/run_eval.py
  • vagen/ray_trainer.py
  • vagen/utils/best_val.py
  • vagen/utils/upload_hugging_face.py

Comment thread vagen/utils/best_val.py
Comment on lines +37 to +41
def __init__(self, config):
self.enabled: bool = bool(config.trainer.get("save_best_val", False))
self.default_local_dir: str = config.trainer.default_local_dir
self.best_score: Optional[float] = None
self.best_step: Optional[int] = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

best_score/best_step are not persisted across runs.

After a training restart (resume from checkpoint), self.best_score is re-initialized to None, so the first post-resume validation will always be treated as a new best and overwrite the on-disk best_val/ — even if its score is worse than the previously saved best. The best_val.json already on disk contains exactly the state needed to recover this.

🛡️ Suggested fix: rehydrate from existing best_val.json on init
     def __init__(self, config):
         self.enabled: bool = bool(config.trainer.get("save_best_val", False))
         self.default_local_dir: str = config.trainer.default_local_dir
         self.best_score: Optional[float] = None
         self.best_step: Optional[int] = None
+        # Rehydrate prior best across restarts so we don't regress to a worse checkpoint.
+        meta_path = os.path.join(self.default_local_dir, "best_val", "best_val.json")
+        if self.enabled and os.path.isfile(meta_path):
+            try:
+                with open(meta_path) as f:
+                    meta = json.load(f)
+                self.best_score = float(meta.get("score"))
+                self.best_step = int(meta.get("step"))
+                print(
+                    f"[BestVal] Rehydrated best score {self.best_score} from step "
+                    f"{self.best_step} ({meta_path})."
+                )
+            except (OSError, ValueError, TypeError, KeyError) as e:
+                print(f"[BestVal] Could not load prior best from {meta_path}: {e}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def __init__(self, config):
self.enabled: bool = bool(config.trainer.get("save_best_val", False))
self.default_local_dir: str = config.trainer.default_local_dir
self.best_score: Optional[float] = None
self.best_step: Optional[int] = None
def __init__(self, config):
self.enabled: bool = bool(config.trainer.get("save_best_val", False))
self.default_local_dir: str = config.trainer.default_local_dir
self.best_score: Optional[float] = None
self.best_step: Optional[int] = None
# Rehydrate prior best across restarts so we don't regress to a worse checkpoint.
meta_path = os.path.join(self.default_local_dir, "best_val", "best_val.json")
if self.enabled and os.path.isfile(meta_path):
try:
with open(meta_path) as f:
meta = json.load(f)
self.best_score = float(meta.get("score"))
self.best_step = int(meta.get("step"))
print(
f"[BestVal] Rehydrated best score {self.best_score} from step "
f"{self.best_step} ({meta_path})."
)
except (OSError, ValueError, TypeError, KeyError) as e:
print(f"[BestVal] Could not load prior best from {meta_path}: {e}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vagen/utils/best_val.py` around lines 37 - 41, In __init__ of the BestVal
helper (the constructor shown), load and parse the existing best_val.json from
the trainer default_local_dir (self.default_local_dir) when save_best_val is
enabled, and set self.best_score and self.best_step from that file instead of
leaving them as None; handle the file not existing or parse errors gracefully
(leave None) and ensure the same path/name used elsewhere for writing best_val
is used when reading so resumed runs correctly rehydrate prior state.

Comment thread vagen/utils/best_val.py
Comment on lines +46 to +62
@staticmethod
def aggregate_val_core_score(val_metrics: dict) -> Optional[float]:
"""Reduce the trainer's ``val-core/...`` metrics to one scalar.

``_validate()`` emits keys shaped like
``val-core/<data_source>/<var>/<metric>@<N>``. Higher = better.
We average all ``val-core`` numeric scalars so the score works
for single- and multi-dataset configs alike. Returns ``None``
when no ``val-core`` scalars exist.
"""
scores = [
float(v) for k, v in val_metrics.items()
if k.startswith("val-core/") and isinstance(v, (int, float))
]
if not scores:
return None
return sum(scores) / len(scores)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Averaging all val-core/ scalars is opinionated and silently broken for mixed-direction or NaN metrics.

A few concerns about aggregate_val_core_score:

  1. isinstance(v, (int, float)) admits bool (Python boolint) and float('nan'). A single NaN poisons the mean and score <= self.best_score becomes False for every subsequent step (NaN comparisons are always False), causing every NaN run to overwrite the saved best.
  2. Different val-core/<data_source>/... metrics may be on different scales (e.g., reward vs. accuracy), so a naive unweighted mean can be dominated by the largest-magnitude metric and is not a stable "higher = better" proxy across configs.
  3. There is no way to opt into a single named metric (e.g., val-core/<ds>/acc/best@N), which would usually be what users want.

Consider filtering out NaN/bool and optionally exposing a configurable key (e.g. trainer.best_val_metric) that, when set, picks one scalar instead of averaging.

🛡️ Minimal NaN/bool guard
-        scores = [
-            float(v) for k, v in val_metrics.items()
-            if k.startswith("val-core/") and isinstance(v, (int, float))
-        ]
+        import math
+        scores = [
+            float(v) for k, v in val_metrics.items()
+            if k.startswith("val-core/")
+            and isinstance(v, (int, float))
+            and not isinstance(v, bool)
+            and not math.isnan(float(v))
+        ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vagen/utils/best_val.py` around lines 46 - 62, The current
aggregate_val_core_score implementation should ignore booleans and NaNs and
optionally allow selecting a single metric name instead of naïve averaging:
update aggregate_val_core_score to accept an optional best_val_metric (or read
trainer.best_val_metric) and if set, try to return that specific key's numeric
value (only if found and not bool/NaN); otherwise filter val_metrics items with
k.startswith("val-core/") and include only numbers where not isinstance(v, bool)
and not math.isnan(float(v)), then return None if no valid values or the mean of
the remaining values; ensure callers that compare to self.best_score get None
for no-valid-metrics so NaNs never propagate.

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.

1 participant